/*
 * 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
 *
 *     https://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.apache.avro;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;

import org.apache.avro.Schema.Field;
import org.apache.avro.Schema.Type;
import org.apache.avro.compiler.specific.TestSpecificCompiler;
import org.apache.avro.data.Json;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericDatumReader;
import org.apache.avro.generic.GenericDatumWriter;
import org.apache.avro.io.DatumReader;
import org.apache.avro.io.DatumWriter;
import org.apache.avro.io.Decoder;
import org.apache.avro.io.DecoderFactory;
import org.apache.avro.io.Encoder;
import org.apache.avro.io.EncoderFactory;
import org.apache.avro.util.RandomData;
import org.apache.avro.util.Utf8;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.io.TempDir;

import static org.junit.jupiter.api.Assertions.*;

public class TestSchema {

  @TempDir
  public File DIR;

  public static final String LISP_SCHEMA = "{\"type\": \"record\", \"name\": \"Lisp\", \"fields\": ["
      + "{\"name\":\"value\", \"type\":[\"null\", \"string\","
      + "{\"type\": \"record\", \"name\": \"Cons\", \"fields\": [" + "{\"name\":\"car\", \"type\":\"Lisp\"},"
      + "{\"name\":\"cdr\", \"type\":\"Lisp\"}]}]}]}";

  public static final String BASIC_ENUM_SCHEMA = "{\"type\":\"enum\", \"name\":\"Test\","
      + "\"symbols\": [\"A\", \"B\"]}";

  public static final String SCHEMA_WITH_DOC_TAGS = "{\n" + "  \"type\": \"record\",\n"
      + "  \"name\": \"outer_record\",\n" + "  \"doc\": \"This is not a world record.\",\n" + "  \"fields\": [\n"
      + "    { \"type\": { \"type\": \"fixed\", \"doc\": \"Very Inner Fixed\", "
      + "                  \"name\": \"very_inner_fixed\", \"size\": 1 },\n"
      + "      \"doc\": \"Inner Fixed\", \"name\": \"inner_fixed\" },\n" + "    { \"type\": \"string\",\n"
      + "      \"name\": \"inner_string\",\n" + "      \"doc\": \"Inner String\" },\n"
      + "    { \"type\": { \"type\": \"enum\", \"doc\": \"Very Inner Enum\", \n"
      + "                  \"name\": \"very_inner_enum\", \n"
      + "                  \"symbols\": [ \"A\", \"B\", \"C\" ] },\n"
      + "      \"doc\": \"Inner Enum\", \"name\": \"inner_enum\" },\n"
      + "    { \"type\": [\"string\", \"int\"], \"doc\": \"Inner Union\", \n" + "      \"name\": \"inner_union\" }\n"
      + "  ]\n" + "}\n";

  private static final int COUNT = Integer.parseInt(System.getProperty("test.count", "30"));

  @Test
  void testNull(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.NULL), SchemaParser.parseSingle("\"null\""));
    assertEquals(Schema.create(Type.NULL), SchemaParser.parseSingle("{\"type\":\"null\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"null\"", "null", null);
  }

  @Test
  void testBoolean(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.BOOLEAN), SchemaParser.parseSingle("\"boolean\""));
    assertEquals(Schema.create(Type.BOOLEAN), SchemaParser.parseSingle("{\"type\":\"boolean\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"boolean\"", "true", Boolean.TRUE);
  }

  @Test
  void string(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.STRING), SchemaParser.parseSingle("\"string\""));
    assertEquals(Schema.create(Type.STRING), SchemaParser.parseSingle("{\"type\":\"string\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"string\"", "\"foo\"", new Utf8("foo"));
  }

  @Test
  void bytes(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.BYTES), SchemaParser.parseSingle("\"bytes\""));
    assertEquals(Schema.create(Type.BYTES), SchemaParser.parseSingle("{\"type\":\"bytes\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"bytes\"", "\"\\u0000ABC\\u00FF\"",
        ByteBuffer.wrap(new byte[] { 0, 65, 66, 67, -1 }));
  }

  @Test
  void testInt(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.INT), SchemaParser.parseSingle("\"int\""));
    assertEquals(Schema.create(Type.INT), SchemaParser.parseSingle("{\"type\":\"int\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"int\"", "9", 9);
  }

  @Test
  void testLong(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.LONG), SchemaParser.parseSingle("\"long\""));
    assertEquals(Schema.create(Type.LONG), SchemaParser.parseSingle("{\"type\":\"long\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"long\"", "11", 11L);
  }

  @Test
  void testFloat(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.FLOAT), SchemaParser.parseSingle("\"float\""));
    assertEquals(Schema.create(Type.FLOAT), SchemaParser.parseSingle("{\"type\":\"float\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"float\"", "1.1", 1.1f);
    checkDefault("\"float\"", "\"NaN\"", Float.NaN);
    checkDefault("\"float\"", "\"Infinity\"", Float.POSITIVE_INFINITY);
    checkDefault("\"float\"", "\"-Infinity\"", Float.NEGATIVE_INFINITY);
  }

  @Test
  void testDouble(TestInfo testInfo) throws Exception {
    assertEquals(Schema.create(Type.DOUBLE), SchemaParser.parseSingle("\"double\""));
    assertEquals(Schema.create(Type.DOUBLE), SchemaParser.parseSingle("{\"type\":\"double\"}"));
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "\"double\"", "1.2", 1.2);
    checkDefault("\"double\"", "\"NaN\"", Double.NaN);
    checkDefault("\"double\"", "\"Infinity\"", Double.POSITIVE_INFINITY);
    checkDefault("\"double\"", "\"-Infinity\"", Double.NEGATIVE_INFINITY);
  }

  @Test
  void array(TestInfo testInfo) throws Exception {
    String json = "{\"type\":\"array\", \"items\": \"long\"}";
    Schema schema = SchemaParser.parseSingle(json);
    Collection<Long> array = new GenericData.Array<>(1, schema);
    array.add(1L);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), json, "[1]", array);
    array = new ArrayList<>(1);
    array.add(1L);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), json, "[1]", array);
    checkParseError("{\"type\":\"array\"}"); // items required
  }

  @Test
  void map(TestInfo testInfo) throws Exception {
    HashMap<Utf8, Long> map = new HashMap<>();
    map.put(new Utf8("a"), 1L);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "{\"type\":\"map\", \"values\":\"long\"}",
        "{\"a\":1}", map);
    checkParseError("{\"type\":\"map\"}"); // values required
  }

  @Test
  void unionMap(TestInfo testInfo) throws Exception {
    String unionMapSchema = "{\"name\":\"foo\", \"type\":\"record\"," + " \"fields\":[ {\"name\":\"mymap\", \"type\":"
        + "   [{\"type\":\"map\", \"values\":" + "      [\"int\",\"long\",\"float\",\"string\"]}," + "    \"null\"]"
        + "   }]" + " }";
    check(new File(DIR, testInfo.getTestMethod().get().getName()), unionMapSchema, true);
  }

  @Test
  void record(TestInfo testInfo) throws Exception {
    String recordJson = "{\"type\":\"record\", \"name\":\"Test\", \"fields\":"
        + "[{\"name\":\"f\", \"type\":\"long\", \"foo\":\"bar\"}]}";
    Schema schema = SchemaParser.parseSingle(recordJson);

    GenericData.Record record = new GenericData.Record(schema);
    record.put("f", 11L);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), recordJson, "{\"f\":11}", record, false);

    // test field props
    assertEquals("bar", schema.getField("f").getProp("foo"));
    assertEquals("bar", SchemaParser.parseSingle(schema.toString()).getField("f").getProp("foo"));
    schema.getField("f").addProp("baz", "boo");
    assertEquals("boo", schema.getField("f").getProp("baz"));

    checkParseError("{\"type\":\"record\"}");
    checkParseError("{\"type\":\"record\",\"name\":\"X\"}");
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":\"Y\"}");
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":" + "[{\"name\":\"f\"}]}"); // no type
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":" + "[{\"type\":\"long\"}]}"); // no name
    // check invalid record names
    checkParseError("{\"type\":\"record\",\"name\":\"1X\",\"fields\":[]}");
    checkParseError("{\"type\":\"record\",\"name\":\"X$\",\"fields\":[]}");
    // check invalid field names
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":[" + "{\"name\":\"1f\",\"type\":\"int\"}]}");
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":[" + "{\"name\":\"f$\",\"type\":\"int\"}]}");
    checkParseError("{\"type\":\"record\",\"name\":\"X\",\"fields\":[" + "{\"name\":\"f.g\",\"type\":\"int\"}]}");
  }

  @Test
  void invalidNameTolerance() {
    JsonSchemaParser.parseInternal("{\"type\":\"record\",\"name\":\"1X\",\"fields\":[]}");
    JsonSchemaParser.parseInternal("{\"type\":\"record\",\"name\":\"X-\",\"fields\":[]}");
    JsonSchemaParser.parseInternal("{\"type\":\"record\",\"name\":\"X$\",\"fields\":[]}");
  }

  @Test
  void mapInRecord(TestInfo testInfo) throws Exception {
    String json = "{\"type\":\"record\", \"name\":\"Test\", \"fields\":"
        + "[{\"name\":\"f\", \"type\": {\"type\":\"map\", \"values\":\"long\"}}]}";
    Schema schema = SchemaParser.parseSingle(json);

    HashMap<Utf8, Long> map = new HashMap<>();
    map.put(new Utf8("a"), 1L);
    GenericData.Record record = new GenericData.Record(schema);
    record.put("f", map);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), json, "{\"f\":{\"a\":1}}", record, false);
  }

  @Test
  void testEnum(TestInfo testInfo) throws Exception {
    check(new File(DIR, testInfo.getTestMethod().get().getName()), BASIC_ENUM_SCHEMA, "\"B\"",
        new GenericData.EnumSymbol(SchemaParser.parseSingle(BASIC_ENUM_SCHEMA), "B"), false);
    checkParseError("{\"type\":\"enum\"}"); // symbols required
    checkParseError("{\"type\":\"enum\",\"symbols\": [\"X\"]}"); // name reqd
    // check no duplicate symbols
    checkParseError("{\"type\":\"enum\",\"name\":\"X\",\"symbols\":[\"X\",\"X\"]}");
    // check no invalid symbols
    checkParseError("{\"type\":\"enum\",\"name\":\"X\",\"symbols\":[\"1X\"]}");
    checkParseError("{\"type\":\"enum\",\"name\":\"X\",\"symbols\":[\"X$\"]}");
    checkParseError("{\"type\":\"enum\",\"name\":\"X\",\"symbols\":[\"X.Y\"]}");
  }

  @Test
  void fixed(TestInfo testInfo) throws Exception {
    String json = "{\"type\": \"fixed\", \"name\":\"Test\", \"size\": 1}";
    Schema schema = SchemaParser.parseSingle(json);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), json, "\"a\"",
        new GenericData.Fixed(schema, new byte[] { (byte) 'a' }), false);
    checkParseError("{\"type\":\"fixed\"}"); // size required
  }

  @Test
  void recursive(TestInfo testInfo) throws Exception {
    check(new File(DIR, testInfo.getTestMethod().get().getName()),
        "{\"type\": \"record\", \"name\": \"Node\", \"fields\": [" + "{\"name\":\"label\", \"type\":\"string\"},"
            + "{\"name\":\"children\", \"type\":" + "{\"type\": \"array\", \"items\": \"Node\" }}]}",
        false);
  }

  @Test
  void recursiveEquals() throws Exception {
    String jsonSchema = "{\"type\":\"record\", \"name\":\"List\", \"fields\": ["
        + "{\"name\":\"next\", \"type\":\"List\"}]}";
    Schema s1 = SchemaParser.parseSingle(jsonSchema);
    Schema s2 = SchemaParser.parseSingle(jsonSchema);
    assertEquals(s1, s2);
    s1.hashCode(); // test no stackoverflow
  }

  /**
   * Test that equals() and hashCode() don't require exponential time on certain
   * pathological schemas.
   */
  @Test
  void schemaExplosion() throws Exception {
    for (int i = 1; i < 15; i++) { // 15 is big enough to trigger
      // create a list of records, each with a single field whose type is a
      // union of all of the records.
      List<Schema> recs = new ArrayList<>();
      for (int j = 0; j < i; j++)
        recs.add(Schema.createRecord("" + (char) ('A' + j), null, null, false));
      for (Schema s : recs) {
        Schema union = Schema.createUnion(recs);
        Field f = new Field("x", union, null, null);
        List<Field> fields = new ArrayList<>();
        fields.add(f);
        s.setFields(fields);
      }
      // check that equals and hashcode are correct and complete in a
      // reasonable amount of time
      for (Schema s1 : recs) {
        Schema s2 = SchemaParser.parseSingle(s1.toString());
        assertEquals(s1.hashCode(), s2.hashCode());
        assertEquals(s1, s2);
      }
    }
  }

  @Test
  void lisp(TestInfo testInfo) throws Exception {
    check(new File(DIR, testInfo.getTestMethod().get().getName()), LISP_SCHEMA, false);
  }

  @Test
  void union(TestInfo testInfo) throws Exception {
    check(new File(DIR, testInfo.getTestMethod().get().getName()), "[\"string\", \"long\"]", false);
    checkDefault("[\"double\", \"long\"]", "1.1", 1.1);
    checkDefault("[\"double\", \"string\"]", "\"TheString\"", new Utf8("TheString"));

    // test that erroneous default values cause errors
    for (String type : new String[] { "int", "long", "float", "double", "string", "bytes", "boolean" }) {
//      checkValidateDefaults("[\"" + type + "\", \"null\"]", "null"); // schema parse time
      checkDefault("[\"" + type + "\", \"null\"]", "null", null); // read time
    }
    checkDefault("[\"null\", \"int\"]", "0", 0);
    checkDefault("[\"null\", \"long\"]", "0", 0l);
    checkDefault("[\"null\", \"float\"]", "0.0", 0.0f);
    checkDefault("[\"null\", \"double\"]", "0.0", 0.0d);
    checkDefault("[\"null\", \"string\"]", "\"Hi\"", new Utf8("Hi"));
    checkDefault("[\"null\", \"bytes\"]", "\"01\"", ByteBuffer.wrap("01".getBytes(StandardCharsets.UTF_8)));
    checkDefault("[\"null\", \"boolean\"]", "true", true);

    // check union json
    String record = "{\"type\":\"record\",\"name\":\"Foo\",\"fields\":[]}";
    String fixed = "{\"type\":\"fixed\",\"name\":\"Bar\",\"size\": 1}";
    String enu = "{\"type\":\"enum\",\"name\":\"Baz\",\"symbols\": [\"X\"]}";
    Schema union = SchemaParser.parseSingle("[\"null\",\"string\"," + record + "," + enu + "," + fixed + "]");
    checkJson(union, null, "null");
    checkJson(union, new Utf8("foo"), "{\"string\":\"foo\"}");
    checkJson(union, new GenericData.Record(SchemaParser.parseSingle(record)), "{\"Foo\":{}}");
    checkJson(union, new GenericData.Fixed(SchemaParser.parseSingle(fixed), new byte[] { (byte) 'a' }),
        "{\"Bar\":\"a\"}");
    checkJson(union, new GenericData.EnumSymbol(SchemaParser.parseSingle(enu), "X"), "{\"Baz\":\"X\"}");
  }

  @Test
  void complexUnions(TestInfo testInfo) throws Exception {
    // one of each unnamed type and two of named types
    String partial = "[\"int\", \"long\", \"float\", \"double\", \"boolean\", \"bytes\","
        + " \"string\", {\"type\":\"array\", \"items\": \"long\"}," + " {\"type\":\"map\", \"values\":\"long\"}";
    String namedTypes = ", {\"type\":\"record\",\"name\":\"Foo\",\"fields\":[]},"
        + " {\"type\":\"fixed\",\"name\":\"Bar\",\"size\": 1},"
        + " {\"type\":\"enum\",\"name\":\"Baz\",\"symbols\": [\"X\"]}";

    String namedTypes2 = ", {\"type\":\"record\",\"name\":\"Foo2\",\"fields\":[]},"
        + " {\"type\":\"fixed\",\"name\":\"Bar2\",\"size\": 1},"
        + " {\"type\":\"enum\",\"name\":\"Baz2\",\"symbols\": [\"X\"]}";

    check(new File(DIR, testInfo.getTestMethod().get().getName()), partial + namedTypes + "]", false);
    check(new File(DIR, testInfo.getTestMethod().get().getName()), partial + namedTypes + namedTypes2 + "]", false);
    checkParseError(partial + namedTypes + namedTypes + "]");

    // fail with two branches of the same unnamed type
    checkUnionError(new Schema[] { Schema.create(Type.INT), Schema.create(Type.INT) });
    checkUnionError(new Schema[] { Schema.create(Type.LONG), Schema.create(Type.LONG) });
    checkUnionError(new Schema[] { Schema.create(Type.FLOAT), Schema.create(Type.FLOAT) });
    checkUnionError(new Schema[] { Schema.create(Type.DOUBLE), Schema.create(Type.DOUBLE) });
    checkUnionError(new Schema[] { Schema.create(Type.BOOLEAN), Schema.create(Type.BOOLEAN) });
    checkUnionError(new Schema[] { Schema.create(Type.BYTES), Schema.create(Type.BYTES) });
    checkUnionError(new Schema[] { Schema.create(Type.STRING), Schema.create(Type.STRING) });
    checkUnionError(
        new Schema[] { Schema.createArray(Schema.create(Type.INT)), Schema.createArray(Schema.create(Type.INT)) });
    checkUnionError(
        new Schema[] { Schema.createMap(Schema.create(Type.INT)), Schema.createMap(Schema.create(Type.INT)) });

    List<String> symbols = new ArrayList<>();
    symbols.add("NOTHING");

    // succeed with two branches of the same named type, if different names
    Schema u;
    u = buildUnion(new Schema[] { SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"x.A\",\"fields\":[]}"),
        SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"y.A\",\"fields\":[]}") });
    check(new File(DIR, testInfo.getTestMethod().get().getName()), u.toString(), false);

    u = buildUnion(new Schema[] { SchemaParser.parseSingle("{\"type\":\"enum\",\"name\":\"x.A\",\"symbols\":[\"X\"]}"),
        SchemaParser.parseSingle("{\"type\":\"enum\",\"name\":\"y.A\",\"symbols\":[\"Y\"]}") });
    check(new File(DIR, testInfo.getTestMethod().get().getName()), u.toString(), false);

    u = buildUnion(new Schema[] { SchemaParser.parseSingle("{\"type\":\"fixed\",\"name\":\"x.A\",\"size\":4}"),
        SchemaParser.parseSingle("{\"type\":\"fixed\",\"name\":\"y.A\",\"size\":8}") });
    check(new File(DIR, testInfo.getTestMethod().get().getName()), u.toString(), false);

    // fail with two branches of the same named type, but same names
    checkUnionError(new Schema[] { Schema.createRecord("Foo", null, "org.test", false),
        Schema.createRecord("Foo", null, "org.test", false) });
    checkUnionError(new Schema[] { Schema.createEnum("Bar", null, "org.test", symbols),
        Schema.createEnum("Bar", null, "org.test", symbols) });
    checkUnionError(new Schema[] { Schema.createFixed("Baz", null, "org.test", 2),
        Schema.createFixed("Baz", null, "org.test", 1) });

    Schema union = buildUnion(new Schema[] { Schema.create(Type.INT) });
    // fail if creating a union of a union
    checkUnionError(new Schema[] { union });
  }

  @Test
  void complexProp() {
    String json = "{\"type\":\"null\", \"foo\": [0]}";
    Schema s = SchemaParser.parseSingle(json);
    assertNull(s.getProp("foo"));
  }

  @Test
  void propOrdering() {
    String json = "{\"type\":\"int\",\"z\":\"c\",\"yy\":\"b\",\"x\":\"a\"}";
    Schema s = SchemaParser.parseSingle(json);
    assertEquals(json, s.toString());
  }

  @Test
  void parseInputStream() throws IOException {
    ByteArrayInputStream in = new ByteArrayInputStream("\"boolean\"".getBytes(StandardCharsets.UTF_8));
    Schema s = new SchemaParser().parse(in).mainSchema();
    assertEquals(SchemaParser.parseSingle("\"boolean\""), s);
  }

  @Test
  void namespaceScope() {
    String z = "{\"type\":\"record\",\"name\":\"Z\",\"fields\":[]}";
    String y = "{\"type\":\"record\",\"name\":\"q.Y\",\"fields\":[" + "{\"name\":\"f\",\"type\":" + z + "}]}";
    String x = "{\"type\":\"record\",\"name\":\"p.X\",\"fields\":[" + "{\"name\":\"f\",\"type\":" + y + "},"
        + "{\"name\":\"g\",\"type\":" + z + "}" + "]}";
    Schema xs = SchemaParser.parseSingle(x);
    Schema ys = xs.getField("f").schema();
    assertEquals("p.Z", xs.getField("g").schema().getFullName());
    assertEquals("q.Z", ys.getField("f").schema().getFullName());
  }

  @Test
  void namespaceNesting() {
    String y = "{\"type\":\"record\",\"name\":\"y.Y\",\"fields\":[" + "{\"name\":\"f\",\"type\":\"x.X\"}]}";
    String x = "{\"type\":\"record\",\"name\":\"x.X\",\"fields\":[" + "{\"name\":\"f\",\"type\":" + y + "}" + "]}";
    Schema xs = SchemaParser.parseSingle(x);
    assertEquals(xs, SchemaParser.parseSingle(xs.toString()));
  }

  @Test
  void nestedNullNamespace() {
    Schema inner = SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"Inner\",\"fields\":[]}");
    Schema outer = Schema.createRecord("Outer", null, "space", false);
    outer.setFields(Collections.singletonList(new Field("f", inner, null, null)));
    assertEquals(outer, SchemaParser.parseSingle(outer.toString()));
  }

  @Test
  void deeplyNestedNullNamespace() {
    Schema inner = SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"Inner\",\"fields\":["
        + "{\"name\":\"x\",\"type\":{\"type\":\"record\",\"name\":\"Deeper\",\"fields\":["
        + "{\"name\":\"y\",\"type\":\"int\"}]}}]}");
    Schema outer = Schema.createRecord("Outer", null, "space", false);
    outer.setFields(Collections.singletonList(new Field("f", inner, null, null)));
    assertEquals(outer, SchemaParser.parseSingle(outer.toString()));
  }

  @Test
  void nestedNullNamespaceReferencing() {
    Schema inner = SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"Inner\",\"fields\":[]}");
    Schema outer = Schema.createRecord("Outer", null, "space", false);
    outer.setFields(Arrays.asList(new Field("f1", inner, null, null), new Field("f2", inner, null, null)));
    assertEquals(outer, SchemaParser.parseSingle(outer.toString()));
  }

  @Test
  void nestedNullNamespaceReferencingWithUnion() {
    Schema inner = SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"Inner\",\"fields\":[]}");
    Schema innerUnion = Schema.createUnion(Arrays.asList(inner, Schema.create(Type.NULL)));
    Schema outer = Schema.createRecord("Outer", null, "space", false);
    outer.setFields(Arrays.asList(new Field("f1", innerUnion, null, null), new Field("f2", innerUnion, null, null)));
    assertEquals(outer, SchemaParser.parseSingle(outer.toString()));
  }

  @Test
  void nestedNonNullNamespace1() {
    Schema inner1 = Schema.createEnum("InnerEnum", null, "space", Collections.singletonList("x"));
    Schema inner2 = SchemaParser
        .parseSingle("{\"type\":\"record\",\"namespace\":\"space\",\"name\":" + "\"InnerRecord\",\"fields\":[]}");
    Schema nullOuter = Schema.createRecord("Outer", null, null, false);
    nullOuter.setFields(Arrays.asList(new Field("f1", inner1, null, null), new Field("f2", inner2, null, null)));
    assertEquals(nullOuter, SchemaParser.parseSingle(nullOuter.toString()));
  }

  @Test
  void nestedNonNullNamespace2() {
    Schema inner1 = Schema.createFixed("InnerFixed", null, "space", 1);
    Schema inner2 = SchemaParser
        .parseSingle("{\"type\":\"record\",\"namespace\":\"space\",\"name\":" + "\"InnerRecord\",\"fields\":[]}");
    Schema nullOuter = Schema.createRecord("Outer", null, null, false);
    nullOuter.setFields(Arrays.asList(new Field("f1", inner1, null, null), new Field("f2", inner2, null, null)));
    assertEquals(nullOuter, SchemaParser.parseSingle(nullOuter.toString()));
  }

  @Test
  void nullNamespaceAlias() {
    Schema s = SchemaParser.parseSingle("{\"type\":\"record\",\"name\":\"Z\",\"fields\":[]}");
    Schema t = SchemaParser
        .parseSingle("{\"type\":\"record\",\"name\":\"x.Y\",\"aliases\":[\".Z\"]," + "\"fields\":[]}");
    Schema u = Schema.applyAliases(s, t);
    assertEquals("x.Y", u.getFullName());
  }

  @Test
  void nullPointer() throws Exception {
    String recordJson = "{\"type\":\"record\", \"name\":\"Test\", \"fields\":"
        + "[{\"name\":\"x\", \"type\":\"string\"}]}";
    Schema schema = SchemaParser.parseSingle(recordJson);
    GenericData.Record record = new GenericData.Record(schema);
    assertThrows(NullPointerException.class,
        () -> checkBinary(schema, record, new GenericDatumWriter<>(), new GenericDatumReader<>()));
  }

  private static void checkParseError(String json) {
    try {
      SchemaParser.parseSingle(json);
    } catch (AvroRuntimeException e) {
      return;
    }
    fail("Should not have parsed: " + json);
  }

  private static void checkUnionError(Schema[] branches) {
    List<Schema> branchList = Arrays.asList(branches);
    try {
      Schema.createUnion(branchList);
      fail("Union should not have constructed from: " + branchList);
    } catch (AvroRuntimeException ignored) {
    }
  }

  private static Schema buildUnion(Schema[] branches) {
    List<Schema> branchList = Arrays.asList(branches);
    return Schema.createUnion(branchList);
  }

  /**
   * Makes sure that "doc" tags are transcribed in the schemas. Note that there
   * are docs both for fields and for the records themselves.
   */
  @Test
  void docs() {
    Schema schema = SchemaParser.parseSingle(SCHEMA_WITH_DOC_TAGS);
    assertEquals("This is not a world record.", schema.getDoc());
    assertEquals("Inner Fixed", schema.getField("inner_fixed").doc());
    assertEquals("Very Inner Fixed", schema.getField("inner_fixed").schema().getDoc());
    assertEquals("Inner String", schema.getField("inner_string").doc());
    assertEquals("Inner Enum", schema.getField("inner_enum").doc());
    assertEquals("Very Inner Enum", schema.getField("inner_enum").schema().getDoc());
    assertEquals("Inner Union", schema.getField("inner_union").doc());
  }

  @Test
  void fieldDocs() {
    String schemaStr = "{\"name\": \"Rec\",\"type\": \"record\",\"fields\" : ["
        + "{\"name\": \"f\", \"type\": \"int\", \"doc\": \"test\"}]}";

    // check field doc is parsed correctly
    Schema schema = SchemaParser.parseSingle(schemaStr);
    assertEquals("test", schema.getField("f").doc());

    // check print/read cycle preserves field doc
    schema = SchemaParser.parseSingle(schema.toString());
    assertEquals("test", schema.getField("f").doc());
  }

  @Test
  void aliases() {
    String t1 = "{\"type\":\"record\",\"name\":\"a.b\",\"fields\":[" + "{\"name\":\"f\",\"type\":\"long\"},"
        + "{\"name\":\"h\",\"type\":\"int\"}]}";
    String t2 = "{\"type\":\"record\",\"name\":\"x.y\",\"aliases\":[\"a.b\"],"
        + "\"fields\":[{\"name\":\"g\",\"type\":\"long\",\"aliases\":[\"f\"]}," + "{\"name\":\"h\",\"type\":\"int\"}]}";
    Schema s1 = SchemaParser.parseSingle(t1);
    Schema s2 = SchemaParser.parseSingle(t2);

    assertEquals(s1.getAliases(), Collections.emptySet());
    assertEquals(s1.getField("f").aliases(), Collections.emptySet());
    assertEquals(s2.getAliases(), Collections.singleton("a.b"));
    assertEquals(s2.getField("g").aliases(), Collections.singleton("f"));

    Schema s3 = Schema.applyAliases(s1, s2);
    assertNotSame(s2, s3);
    assertEquals(s2, s3);

    t1 = "{\"type\":\"enum\",\"name\":\"a.b\"," + "\"symbols\":[\"x\"]}";
    t2 = "{\"type\":\"enum\",\"name\":\"a.c\",\"aliases\":[\"b\"]," + "\"symbols\":[\"x\"]}";
    s1 = SchemaParser.parseSingle(t1);
    s2 = SchemaParser.parseSingle(t2);
    s3 = Schema.applyAliases(s1, s2);
    assertNotSame(s2, s3);
    assertEquals(s2, s3);

    t1 = "{\"type\":\"fixed\",\"name\":\"a\"," + "\"size\": 5}";
    t2 = "{\"type\":\"fixed\",\"name\":\"b\",\"aliases\":[\"a\"]," + "\"size\": 5}";
    s1 = SchemaParser.parseSingle(t1);
    s2 = SchemaParser.parseSingle(t2);
    s3 = Schema.applyAliases(s1, s2);
    assertNotSame(s2, s3);
    assertEquals(s2, s3);
  }

  @Test
  void aliasesSelfReferential() {
    String t1 = "{\"type\":\"record\",\"name\":\"a\",\"fields\":[{\"name\":\"f\",\"type\":{\"type\":\"record\",\"name\":\"C\",\"fields\":[{\"name\":\"c\",\"type\":{\"type\":\"array\",\"items\":[\"null\",\"C\"]}}]}}]}";
    String t2 = "{\"type\":\"record\",\"name\":\"x\",\"fields\":[{\"name\":\"f\",\"type\":{\"type\":\"record\",\"name\":\"C\",\"fields\":[{\"name\":\"d\",\"type\":{\"type\":\"array\",\"items\":[\"null\",\"C\"]},\"aliases\":[\"c\"]}]}}],\"aliases\":[\"a\"]}";
    Schema s1 = SchemaParser.parseSingle(t1);
    Schema s2 = SchemaParser.parseSingle(t2);

    assertEquals(s1.getAliases(), Collections.emptySet());
    assertEquals(s2.getAliases(), Collections.singleton("a"));

    Schema s3 = Schema.applyAliases(s1, s2);
    assertNotSame(s2, s3);
    assertEquals(s2, s3);
  }

  private static void check(File dst, String schemaJson, String defaultJson, Object defaultValue) throws Exception {
    check(dst, schemaJson, defaultJson, defaultValue, true);
  }

  private static void check(File dst, String schemaJson, String defaultJson, Object defaultValue, boolean induce)
      throws Exception {
    check(dst, schemaJson, induce);
    checkDefault(schemaJson, defaultJson, defaultValue);
  }

  private static void check(File dst, String jsonSchema, boolean induce) throws Exception {
    Schema schema = SchemaParser.parseSingle(jsonSchema);
    checkProp(schema);
    Object reuse = null;
    for (Object datum : new RandomData(schema, COUNT, true)) {

      if (induce) {
        Schema induced = GenericData.get().induce(datum);
        assertEquals(schema, induced, "Induced schema does not match.");
      }

      assertTrue(GenericData.get().validate(schema, datum), "Datum does not validate against schema " + datum);

      checkBinary(schema, datum, new GenericDatumWriter<>(), new GenericDatumReader<>(), null);
      reuse = checkBinary(schema, datum, new GenericDatumWriter<>(), new GenericDatumReader<>(), reuse);
      checkDirectBinary(schema, datum, new GenericDatumWriter<>(), new GenericDatumReader<>());
      checkBlockingBinary(schema, datum, new GenericDatumWriter<>(), new GenericDatumReader<>());
      checkJson(schema, datum, new GenericDatumWriter<>(), new GenericDatumReader<>());

      // Check that we can generate the code for every schema we see.
      TestSpecificCompiler.assertCompiles(dst, schema, false);

      // Check that we can read/write the json of every schema we see.
      checkBinaryJson(jsonSchema);
    }
  }

  private static void checkProp(Schema s0) throws Exception {
    if (s0.getType().equals(Schema.Type.UNION))
      return; // unions have no props
    assertNull(s0.getProp("foo"));
    Schema s1 = SchemaParser.parseSingle(s0.toString());
    s1.addProp("foo", "bar");
    assertEquals("bar", s1.getProp("foo"));
    assertNotEquals(s0, s1);
    Schema s2 = SchemaParser.parseSingle(s1.toString());
    assertEquals("bar", s2.getProp("foo"));
    assertEquals(s1, s2);
    assertNotEquals(s0, s2);
  }

  public static void checkBinary(Schema schema, Object datum, DatumWriter<Object> writer, DatumReader<Object> reader)
      throws IOException {
    checkBinary(schema, datum, writer, reader, null);
  }

  public static Object checkBinary(Schema schema, Object datum, DatumWriter<Object> writer, DatumReader<Object> reader,
      Object reuse) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    writer.setSchema(schema);
    Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
    writer.write(datum, encoder);
    encoder.flush();
    byte[] data = out.toByteArray();

    reader.setSchema(schema);

    Object decoded = reader.read(reuse, DecoderFactory.get().binaryDecoder(data, null));

    assertEquals(datum, decoded, "Decoded data does not match.");
    return decoded;
  }

  public static void checkDirectBinary(Schema schema, Object datum, DatumWriter<Object> writer,
      DatumReader<Object> reader) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    writer.setSchema(schema);
    Encoder encoder = EncoderFactory.get().directBinaryEncoder(out, null);
    writer.write(datum, encoder);
    // no flush for direct
    byte[] data = out.toByteArray();

    reader.setSchema(schema);

    Object decoded = reader.read(null, DecoderFactory.get().binaryDecoder(data, null));

    assertEquals(datum, decoded, "Decoded data does not match.");
  }

  public static void checkBlockingBinary(Schema schema, Object datum, DatumWriter<Object> writer,
      DatumReader<Object> reader) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    writer.setSchema(schema);
    Encoder encoder = EncoderFactory.get().blockingBinaryEncoder(out, null);
    writer.write(datum, encoder);
    encoder.flush();
    byte[] data = out.toByteArray();

    reader.setSchema(schema);

    Object decoded = reader.read(null, DecoderFactory.get().binaryDecoder(data, null));

    assertEquals(datum, decoded, "Decoded data does not match.");
  }

  private static void checkJson(Schema schema, Object datum, DatumWriter<Object> writer, DatumReader<Object> reader)
      throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    Encoder encoder = EncoderFactory.get().jsonEncoder(schema, out);
    writer.setSchema(schema);
    writer.write(datum, encoder);
    writer.write(datum, encoder);
    encoder.flush();
    byte[] data = out.toByteArray();

    reader.setSchema(schema);
    Decoder decoder = DecoderFactory.get().jsonDecoder(schema, new ByteArrayInputStream(data));
    Object decoded = reader.read(null, decoder);
    assertEquals(datum, decoded, "Decoded data does not match.");

    decoded = reader.read(decoded, decoder);
    assertEquals(datum, decoded, "Decoded data does not match.");
  }

  private static void checkJson(Schema schema, Object datum, String json) throws Exception {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    Encoder encoder = EncoderFactory.get().jsonEncoder(schema, out);
    DatumWriter<Object> writer = new GenericDatumWriter<>();
    writer.setSchema(schema);
    writer.write(datum, encoder);
    encoder.flush();
    byte[] data = out.toByteArray();

    String encoded = new String(data, StandardCharsets.UTF_8);
    assertEquals(json, encoded, "Encoded data does not match.");

    DatumReader<Object> reader = new GenericDatumReader<>();
    reader.setSchema(schema);
    Object decoded = reader.read(null, DecoderFactory.get().jsonDecoder(schema, new ByteArrayInputStream(data)));

    assertEquals(datum, decoded, "Decoded data does not match.");
  }

  public static void checkBinaryJson(String json) throws Exception {
    Object node = Json.parseJson(json);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    DatumWriter<Object> writer = new Json.ObjectWriter();
    Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
    encoder = EncoderFactory.get().validatingEncoder(Json.SCHEMA, encoder);
    writer.write(node, encoder);
    encoder.flush();
    byte[] bytes = out.toByteArray();

    DatumReader<Object> reader = new Json.ObjectReader();
    Decoder decoder = DecoderFactory.get().binaryDecoder(bytes, null);
    decoder = DecoderFactory.get().validatingDecoder(Json.SCHEMA, decoder);
    Object decoded = reader.read(null, decoder);

    assertEquals(Json.toString(node), Json.toString(decoded), "Decoded json does not match.");
  }

  private static final Schema ACTUAL = // an empty record schema
      SchemaParser.parseSingle("{\"type\":\"record\", \"name\":\"Foo\", \"fields\":[]}");

  private static void checkDefault(String schemaJson, String defaultJson, Object defaultValue) throws Exception {
    String recordJson = "{\"type\":\"record\", \"name\":\"Foo\", \"fields\":[{\"name\":\"f\", " + "\"type\":"
        + schemaJson + ", " + "\"default\":" + defaultJson + "}]}";
    Schema expected = SchemaParser.parseSingle(recordJson);
    DatumReader<Object> in = new GenericDatumReader<>(ACTUAL, expected);
    GenericData.Record record = (GenericData.Record) in.read(null,
        DecoderFactory.get().binaryDecoder(new byte[0], null));
    assertEquals(defaultValue, record.get("f"), "Wrong default.");
    assertEquals(expected, SchemaParser.parseSingle(expected.toString()), "Wrong toString");
  }

  private static void checkValidateDefaults(String schemaJson, String defaultJson) {
    try {
      SchemaParser parser = new SchemaParser();
      String recordJson = "{\"type\":\"record\", \"name\":\"Foo\", \"fields\":[{\"name\":\"f\", " + "\"type\":"
          + schemaJson + ", " + "\"default\":" + defaultJson + "}]}";
      parser.parse(recordJson);
      fail("Schema of type " + schemaJson + " should not have default " + defaultJson);
    } catch (AvroTypeException ignored) {
    }
  }

  @Test
  void noDefaultField() throws Exception {
    assertThrows(AvroTypeException.class, () -> {
      Schema expected = SchemaParser.parseSingle(
          "{\"type\":\"record\", \"name\":\"Foo\", \"fields\":" + "[{\"name\":\"f\", \"type\": \"string\"}]}");
      DatumReader<Object> in = new GenericDatumReader<>(ACTUAL, expected);
      in.read(null, DecoderFactory.get().binaryDecoder(new ByteArrayInputStream(new byte[0]), null));
    });
  }

  @Test
  void enumMismatch() throws Exception {
    Schema actual = SchemaParser.parseSingle("{\"type\":\"enum\",\"name\":\"E\",\"symbols\":[\"X\",\"Y\"]}");
    Schema expected = SchemaParser.parseSingle("{\"type\":\"enum\",\"name\":\"E\",\"symbols\":[\"Y\",\"Z\"]}");
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    DatumWriter<Object> writer = new GenericDatumWriter<>(actual);
    Encoder encoder = EncoderFactory.get().directBinaryEncoder(out, null);
    writer.write(new GenericData.EnumSymbol(actual, "Y"), encoder);
    writer.write(new GenericData.EnumSymbol(actual, "X"), encoder);
    encoder.flush();
    byte[] data = out.toByteArray();
    Decoder decoder = DecoderFactory.get().binaryDecoder(data, null);
    DatumReader<String> in = new GenericDatumReader<>(actual, expected);
    assertEquals(new GenericData.EnumSymbol(expected, "Y"), in.read(null, decoder), "Wrong value");
    try {
      in.read(null, decoder);
      fail("Should have thrown exception.");
    } catch (AvroTypeException e) {
      // expected
    }
  }

  @Test
  void recordWithPrimitiveName() {
    assertThrows(AvroTypeException.class, () -> {
      SchemaParser.parseSingle("{\"type\":\"record\", \"name\":\"string\", \"fields\": []}");
    });
  }

  @Test
  void enumWithPrimitiveName() {
    assertThrows(AvroTypeException.class, () -> {
      SchemaParser.parseSingle("{\"type\":\"enum\", \"name\":\"null\", \"symbols\": [\"A\"]}");
    });
  }

  private static Schema enumSchema() {
    return SchemaParser.parseSingle("{ \"type\": \"enum\", \"name\": \"e\", " + "\"symbols\": [\"a\", \"b\"]}");
  }

  @Test
  void immutability1() {
    assertThrows(AvroRuntimeException.class, () -> {
      Schema s = enumSchema();
      s.addProp("p1", "1");
      s.addProp("p1", "2");
    });
  }

  @Test
  void immutability2() {
    assertThrows(AvroRuntimeException.class, () -> {
      Schema s = enumSchema();
      s.addProp("p1", null);
    });
  }

  private static List<String> lockedArrayList() {
    return new Schema.LockableArrayList<>(Arrays.asList("a", "b", "c")).lock();
  }

  @Test
  void lockedArrayList1() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().add("p");
    });
  }

  @Test
  void lockedArrayList2() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().remove("a");
    });
  }

  @Test
  void lockedArrayList3() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().addAll(Collections.singletonList("p"));
    });
  }

  @Test
  void lockedArrayList4() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().addAll(0, Collections.singletonList("p"));
    });
  }

  @Test
  void lockedArrayList5() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().removeAll(Collections.singletonList("a"));
    });
  }

  @Test
  void lockedArrayList6() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().retainAll(Collections.singletonList("a"));
    });
  }

  @Test
  void lockedArrayList7() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().clear();
    });
  }

  @Test
  void lockedArrayList8() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().iterator().remove();
    });
  }

  @Test
  void lockedArrayList9() {
    assertThrows(IllegalStateException.class, () -> {
      Iterator<String> it = lockedArrayList().iterator();
      it.next();
      it.remove();
    });
  }

  @Test
  void lockedArrayList10() {
    assertThrows(IllegalStateException.class, () -> {
      lockedArrayList().remove(1);
    });
  }

  @Test
  void names_GetWithInheritedNamespace() {
    Schema schema = Schema.create(Type.STRING);
    Schema.Names names = new Schema.Names("space");
    names.put(new Schema.Name("Name", "space"), schema);

    assertEquals(schema, names.get(new Schema.Name("Name", "space")));
    assertEquals(schema, names.get("Name"));
  }

  @Test
  void names_GetWithNullNamespace() {
    Schema schema = Schema.create(Type.STRING);
    Schema.Names names = new Schema.Names("space");
    names.put(new Schema.Name("Name", ""), schema);

    assertEquals(schema, names.get(new Schema.Name("Name", "")));
    assertEquals(schema, names.get("Name"));
  }

  @Test
  void names_GetNotFound() {
    Schema.Names names = new Schema.Names("space");
    names.put(new Schema.Name("Name", "otherspace"), Schema.create(Type.STRING));

    assertNull(names.get("Name"));
  }
}
