/*
 * 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.compiler.specific;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.apache.avro.AvroTypeException;
import org.apache.avro.LogicalType;
import org.apache.avro.LogicalTypes;
import org.apache.avro.Protocol;
import org.apache.avro.Schema;
import org.apache.avro.SchemaBuilder;
import org.apache.avro.SchemaParser;
import org.apache.avro.generic.GenericData.StringType;
import org.apache.avro.specific.SpecificData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticListener;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

public class TestSpecificCompiler {
  private static final Logger LOG = LoggerFactory.getLogger(TestSpecificCompiler.class);

  /**
   * JDK 18+ generates a warning for each member field which does not implement
   * java.io.Serializable. Since Avro is an alternative serialization format, we
   * can just ignore this warning.
   */
  private static final String NON_TRANSIENT_INSTANCE_FIELD_MESSAGE = "non-transient instance field of a serializable class declared with a non-serializable type";

  @TempDir
  public File OUTPUT_DIR;

  private File outputFile;

  @BeforeEach
  public void setUp() {
    this.outputFile = new File(this.OUTPUT_DIR, "SimpleRecord.java");
  }

  private File src = new File("src/test/resources/simple_record.avsc");

  static void assertCompilesWithJavaCompiler(File dstDir, Collection<SpecificCompiler.OutputFile> outputs)
      throws IOException {
    assertCompilesWithJavaCompiler(dstDir, outputs, true);
  }

  /**
   * Uses the system's java compiler to actually compile the generated code.
   */
  static void assertCompilesWithJavaCompiler(File dstDir, Collection<SpecificCompiler.OutputFile> outputs,
      boolean ignoreWarnings) throws IOException {
    if (outputs.isEmpty()) {
      return; // Nothing to compile!
    }

    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);

    List<File> javaFiles = new ArrayList<>();
    for (SpecificCompiler.OutputFile o : outputs) {
      javaFiles.add(o.writeToDestination(null, dstDir));
    }

    final List<Diagnostic<?>> warnings = new ArrayList<>();
    DiagnosticListener<JavaFileObject> diagnosticListener = diagnostic -> {
      switch (diagnostic.getKind()) {
      case ERROR:
        // Do not add these to warnings because they will fail the compilation, anyway.
        LOG.error("{}", diagnostic);
        break;
      case WARNING:
      case MANDATORY_WARNING:
        String message = diagnostic.getMessage(Locale.ROOT);
        if (!NON_TRANSIENT_INSTANCE_FIELD_MESSAGE.equals(message)) {
          LOG.warn("{}", diagnostic);
          warnings.add(diagnostic);
        }
        break;
      case NOTE:
      case OTHER:
        LOG.debug("{}", diagnostic);
        break;
      }
    };
    JavaCompiler.CompilationTask cTask = compiler.getTask(null, fileManager, diagnosticListener,
        Collections.singletonList("-Xlint:all"), null, fileManager.getJavaFileObjects(javaFiles.toArray(new File[0])));
    boolean compilesWithoutError = cTask.call();
    assertTrue(compilesWithoutError);
    if (!ignoreWarnings) {
      assertEquals(List.of(), warnings, "Warnings produced when compiling generated code with -Xlint:all");
    }
  }

  private static Schema createSampleRecordSchema(int numStringFields, int numDoubleFields) {
    SchemaBuilder.FieldAssembler<Schema> sb = SchemaBuilder.record("sample.record").fields();
    for (int i = 0; i < numStringFields; i++) {
      sb.name("sf_" + i).type().stringType().noDefault();
    }
    for (int i = 0; i < numDoubleFields; i++) {
      sb.name("df_" + i).type().doubleType().noDefault();
    }
    return sb.endRecord();
  }

  private SpecificCompiler createCompiler() throws IOException {
    SchemaParser parser = new SchemaParser();
    Schema schema = parser.parse(this.src).mainSchema();
    SpecificCompiler compiler = new SpecificCompiler(schema);
    String velocityTemplateDir = "src/main/velocity/org/apache/avro/compiler/specific/templates/java/classic/";
    compiler.setTemplateDir(velocityTemplateDir);
    compiler.setStringType(StringType.CharSequence);
    return compiler;
  }

  @Test
  void canReadTemplateFilesOnTheFilesystem() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(new File(OUTPUT_DIR, "SimpleRecord.java").exists());
  }

  @Test
  void publicFieldVisibility() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setFieldVisibility(SpecificCompiler.FieldVisibility.PUBLIC);
    assertTrue(compiler.publicFields());
    assertFalse(compiler.privateFields());
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        // No line, once trimmed, should start with a deprecated field declaration
        // nor a private field declaration. Since the nested builder uses private
        // fields, we cannot do the second check.
        line = line.trim();
        assertFalse(line.startsWith("@Deprecated public int value"),
            "Line started with a deprecated field declaration: " + line);
      }
    }
  }

  @Test
  void createAllArgsConstructor() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    boolean foundAllArgsConstructor = false;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while (!foundAllArgsConstructor && (line = reader.readLine()) != null) {
        foundAllArgsConstructor = line.contains("All-args constructor");
      }
    }
    assertTrue(foundAllArgsConstructor);
  }

  @Test
  void maxValidParameterCounts() throws Exception {
    Schema validSchema1 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT, 0);
    assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, "testMaxValidParameterCounts1"),
        new SpecificCompiler(validSchema1).compile());

    createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT - 2, 1);
    assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, "testMaxValidParameterCounts2"),
        new SpecificCompiler(validSchema1).compile());
  }

  @Test
  void invalidParameterCounts() throws Exception {
    Schema invalidSchema1 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT + 1, 0);
    SpecificCompiler compiler = new SpecificCompiler(invalidSchema1);
    assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, "testInvalidParameterCounts1"), compiler.compile());

    Schema invalidSchema2 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT, 10);
    compiler = new SpecificCompiler(invalidSchema2);
    assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, "testInvalidParameterCounts2"), compiler.compile());
  }

  @Test
  void maxParameterCounts() throws Exception {
    Schema validSchema1 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT, 0);
    assertTrue(new SpecificCompiler(validSchema1).compile().size() > 0);

    Schema validSchema2 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT - 2, 1);
    assertTrue(new SpecificCompiler(validSchema2).compile().size() > 0);

    Schema validSchema3 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT - 1, 1);
    assertTrue(new SpecificCompiler(validSchema3).compile().size() > 0);

    Schema validSchema4 = createSampleRecordSchema(SpecificCompiler.MAX_FIELD_PARAMETER_UNIT_COUNT + 1, 0);
    assertTrue(new SpecificCompiler(validSchema4).compile().size() > 0);
  }

  @Test
  void calcAllArgConstructorParameterUnitsFailure() {
    assertThrows(RuntimeException.class, () -> {
      Schema nonRecordSchema = SchemaBuilder.array().items().booleanType();
      new SpecificCompiler().calcAllArgConstructorParameterUnits(nonRecordSchema);
    });
  }

  @Test
  void privateFieldVisibility() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setFieldVisibility(SpecificCompiler.FieldVisibility.PRIVATE);
    assertFalse(compiler.publicFields());
    assertTrue(compiler.privateFields());
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line = null;
      while ((line = reader.readLine()) != null) {
        // No line, once trimmed, should start with a public field declaration
        // or with a deprecated public field declaration
        line = line.trim();
        assertFalse(line.startsWith("public int value"), "Line started with a public field declaration: " + line);
        assertFalse(line.startsWith("@Deprecated public int value"),
            "Line started with a deprecated field declaration: " + line);
      }
    }
  }

  @Test
  void settersCreatedByDefault() throws IOException {
    SpecificCompiler compiler = createCompiler();
    assertTrue(compiler.isCreateSetters());
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    int foundSetters = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        // We should find the setter in the main class
        line = line.trim();
        if (line.startsWith("public void setValue(")) {
          foundSetters++;
        }
      }
    }
    assertEquals(1, foundSetters, "Found the wrong number of setters");
  }

  @Test
  void settersNotCreatedWhenOptionTurnedOff() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setCreateSetters(false);
    assertFalse(compiler.isCreateSetters());
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        // No setter should be found
        line = line.trim();
        assertFalse(line.startsWith("public void setValue("), "No line should include the setter: " + line);
      }
    }
  }

  @Test
  void settingOutputCharacterEncoding() throws Exception {
    SpecificCompiler compiler = createCompiler();
    // Generated file in default encoding
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    byte[] fileInDefaultEncoding = new byte[(int) this.outputFile.length()];
    FileInputStream is = new FileInputStream(this.outputFile);
    is.read(fileInDefaultEncoding);
    is.close(); // close input stream otherwise delete might fail
    if (!this.outputFile.delete()) {
      throw new IllegalStateException("unable to delete " + this.outputFile); // delete otherwise compiler might not
      // overwrite because src timestamp hasn't
      // changed.
    }
    // Generate file in another encoding (make sure it has different number of bytes
    // per character)
    String differentEncoding = Charset.defaultCharset().equals(Charset.forName("UTF-16")) ? "UTF-32" : "UTF-16";
    compiler.setOutputCharacterEncoding(differentEncoding);
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    byte[] fileInDifferentEncoding = new byte[(int) this.outputFile.length()];
    is = new FileInputStream(this.outputFile);
    is.read(fileInDifferentEncoding);
    is.close();
    // Compare as bytes
    assertThat("Generated file should contain different bytes after setting non-default encoding",
        fileInDefaultEncoding, not(equalTo(fileInDifferentEncoding)));
    // Compare as strings
    assertThat("Generated files should contain the same characters in the proper encodings",
        new String(fileInDefaultEncoding), equalTo(new String(fileInDifferentEncoding, differentEncoding)));
  }

  @Test
  void javaTypeWithDecimalLogicalTypeEnabled() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    Schema localTimestampSchema = LogicalTypes.localTimestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    Schema decimalSchema = LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES));
    Schema uuidSchema = LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING));

    // Date/time types should always use upper level java classes
    // Decimal type target class depends on configuration
    // UUID should always be CharSequence since we haven't added its
    // support in SpecificRecord
    assertEquals("java.time.LocalDate", compiler.javaType(dateSchema), "Should use LocalDate for date type");
    assertEquals("java.time.LocalTime", compiler.javaType(timeSchema), "Should use LocalTime for time-millis type");
    assertEquals("java.time.Instant", compiler.javaType(timestampSchema),
        "Should use DateTime for timestamp-millis type");
    assertEquals("java.time.LocalDateTime", compiler.javaType(localTimestampSchema),
        "Should use LocalDateTime for local-timestamp-millis type");
    assertEquals("java.math.BigDecimal", compiler.javaType(decimalSchema), "Should use Java BigDecimal type");
    assertEquals("new org.apache.avro.Conversions.UUIDConversion()", compiler.conversionInstance(uuidSchema),
        "Should use org.apache.avro.Conversions.UUIDConversion() type");
  }

  @Test
  void javaTypeWithDecimalLogicalTypeDisabled() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(false);

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    Schema decimalSchema = LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES));
    Schema uuidSchema = LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING));

    // Date/time types should always use upper level java classes
    // Decimal type target class depends on configuration
    // UUID should always be CharSequence since we haven't added its
    // support in SpecificRecord
    assertEquals("java.time.LocalDate", compiler.javaType(dateSchema), "Should use LocalDate for date type");
    assertEquals("java.time.LocalTime", compiler.javaType(timeSchema), "Should use LocalTime for time-millis type");
    assertEquals("java.time.Instant", compiler.javaType(timestampSchema),
        "Should use DateTime for timestamp-millis type");
    assertEquals("java.nio.ByteBuffer", compiler.javaType(decimalSchema), "Should use ByteBuffer type");
    assertEquals("new org.apache.avro.Conversions.UUIDConversion()", compiler.conversionInstance(uuidSchema),
        "Should use org.apache.avro.Conversions.UUIDConversion() type");
  }

  @Test
  void javaTypeWithDateTimeTypes() throws Exception {
    SpecificCompiler compiler = createCompiler();

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeMicrosSchema = LogicalTypes.timeMicros().addToSchema(Schema.create(Schema.Type.LONG));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    Schema timestampMicrosSchema = LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG));
    Schema timestampNanosSchema = LogicalTypes.timestampNanos().addToSchema(Schema.create(Schema.Type.LONG));

    // Date/time types should always use upper level java classes
    assertEquals("java.time.LocalDate", compiler.javaType(dateSchema), "Should use java.time.LocalDate for date type");
    assertEquals("java.time.LocalTime", compiler.javaType(timeSchema),
        "Should use java.time.LocalTime for time-millis type");
    assertEquals("java.time.Instant", compiler.javaType(timestampSchema),
        "Should use java.time.Instant for timestamp-millis type");
    assertEquals("java.time.LocalTime", compiler.javaType(timeMicrosSchema),
        "Should use java.time.LocalTime for time-micros type");
    assertEquals("java.time.Instant", compiler.javaType(timestampMicrosSchema),
        "Should use java.time.Instant for timestamp-micros type");
    assertEquals("java.time.Instant", compiler.javaType(timestampNanosSchema),
        "Should use java.time.Instant for timestamp-nanos type");
  }

  @Test
  void javaUnbox() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(false);

    Schema intSchema = Schema.create(Schema.Type.INT);
    Schema longSchema = Schema.create(Schema.Type.LONG);
    Schema floatSchema = Schema.create(Schema.Type.FLOAT);
    Schema doubleSchema = Schema.create(Schema.Type.DOUBLE);
    Schema boolSchema = Schema.create(Schema.Type.BOOLEAN);
    assertEquals("int", compiler.javaUnbox(intSchema, false), "Should use int for Type.INT");
    assertEquals("long", compiler.javaUnbox(longSchema, false), "Should use long for Type.LONG");
    assertEquals("float", compiler.javaUnbox(floatSchema, false), "Should use float for Type.FLOAT");
    assertEquals("double", compiler.javaUnbox(doubleSchema, false), "Should use double for Type.DOUBLE");
    assertEquals("boolean", compiler.javaUnbox(boolSchema, false), "Should use boolean for Type.BOOLEAN");

    // see AVRO-2569
    Schema nullSchema = Schema.create(Schema.Type.NULL);
    assertEquals("void", compiler.javaUnbox(nullSchema, true), "Should use void for Type.NULL");

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    // Date/time types should always use upper level java classes, even though
    // their underlying representations are primitive types
    assertEquals("java.time.LocalDate", compiler.javaUnbox(dateSchema, false), "Should use LocalDate for date type");
    assertEquals("java.time.LocalTime", compiler.javaUnbox(timeSchema, false),
        "Should use LocalTime for time-millis type");
    assertEquals("java.time.Instant", compiler.javaUnbox(timestampSchema, false),
        "Should use DateTime for timestamp-millis type");
  }

  @Test
  void javaUnboxDateTime() throws Exception {
    SpecificCompiler compiler = createCompiler();

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    // Date/time types should always use upper level java classes, even though
    // their underlying representations are primitive types
    assertEquals("java.time.LocalDate", compiler.javaUnbox(dateSchema, false),
        "Should use java.time.LocalDate for date type");
    assertEquals("java.time.LocalTime", compiler.javaUnbox(timeSchema, false),
        "Should use java.time.LocalTime for time-millis type");
    assertEquals("java.time.Instant", compiler.javaUnbox(timestampSchema, false),
        "Should use java.time.Instant for timestamp-millis type");
  }

  @Test
  void nullableLogicalTypesJavaUnboxDecimalTypesEnabled() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    // Nullable types should return boxed types instead of primitive types
    Schema nullableDecimalSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL),
        LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES)));
    Schema nullableDecimalSchema2 = Schema.createUnion(
        LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES)), Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableDecimalSchema1, false), "java.math.BigDecimal", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableDecimalSchema2, false), "java.math.BigDecimal", "Should return boxed type");
  }

  @Test
  void nullableLogicalTypesJavaUnboxDecimalTypesDisabled() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(false);

    // Since logical decimal types are disabled, a ByteBuffer is expected.
    Schema nullableDecimalSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL),
        LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES)));
    Schema nullableDecimalSchema2 = Schema.createUnion(
        LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES)), Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableDecimalSchema1, false), "java.nio.ByteBuffer", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableDecimalSchema2, false), "java.nio.ByteBuffer", "Should return boxed type");
  }

  @Test
  void nullableTypesJavaUnbox() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(false);

    // Nullable types should return boxed types instead of primitive types
    Schema nullableIntSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.INT));
    Schema nullableIntSchema2 = Schema.createUnion(Schema.create(Schema.Type.INT), Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableIntSchema1, false), "java.lang.Integer", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableIntSchema2, false), "java.lang.Integer", "Should return boxed type");

    Schema nullableLongSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.LONG));
    Schema nullableLongSchema2 = Schema.createUnion(Schema.create(Schema.Type.LONG), Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableLongSchema1, false), "java.lang.Long", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableLongSchema2, false), "java.lang.Long", "Should return boxed type");

    Schema nullableFloatSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.FLOAT));
    Schema nullableFloatSchema2 = Schema.createUnion(Schema.create(Schema.Type.FLOAT), Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableFloatSchema1, false), "java.lang.Float", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableFloatSchema2, false), "java.lang.Float", "Should return boxed type");

    Schema nullableDoubleSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL),
        Schema.create(Schema.Type.DOUBLE));
    Schema nullableDoubleSchema2 = Schema.createUnion(Schema.create(Schema.Type.DOUBLE),
        Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableDoubleSchema1, false), "java.lang.Double", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableDoubleSchema2, false), "java.lang.Double", "Should return boxed type");

    Schema nullableBooleanSchema1 = Schema.createUnion(Schema.create(Schema.Type.NULL),
        Schema.create(Schema.Type.BOOLEAN));
    Schema nullableBooleanSchema2 = Schema.createUnion(Schema.create(Schema.Type.BOOLEAN),
        Schema.create(Schema.Type.NULL));
    assertEquals(compiler.javaUnbox(nullableBooleanSchema1, false), "java.lang.Boolean", "Should return boxed type");
    assertEquals(compiler.javaUnbox(nullableBooleanSchema2, false), "java.lang.Boolean", "Should return boxed type");
  }

  @Test
  void getUsedCustomLogicalTypeFactories() throws Exception {
    LogicalTypes.register("string-custom", new StringCustomLogicalTypeFactory());

    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    final Schema schema = SchemaParser.parseSingle("{\"type\":\"record\"," + "\"name\":\"NestedLogicalTypesRecord\","
        + "\"namespace\":\"org.apache.avro.codegentest.testdata\","
        + "\"doc\":\"Test nested types with logical types in generated Java classes\"," + "\"fields\":["
        + "{\"name\":\"nestedRecord\",\"type\":" + "{\"type\":\"record\",\"name\":\"NestedRecord\",\"fields\":"
        + "[{\"name\":\"nullableDateField\"," + "\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]}},"
        + "{\"name\":\"myLogical\",\"type\":{\"type\":\"string\",\"logicalType\":\"string-custom\"}}]}");

    final Map<String, String> usedCustomLogicalTypeFactories = compiler.getUsedCustomLogicalTypeFactories(schema);
    assertEquals(1, usedCustomLogicalTypeFactories.size());
    final Map.Entry<String, String> entry = usedCustomLogicalTypeFactories.entrySet().iterator().next();
    assertEquals("string-custom", entry.getKey());
    assertEquals("org.apache.avro.compiler.specific.TestSpecificCompiler.StringCustomLogicalTypeFactory",
        entry.getValue());
  }

  @Test
  void emptyGetUsedCustomLogicalTypeFactories() throws Exception {
    LogicalTypes.register("string-custom", new StringCustomLogicalTypeFactory());

    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    final Schema schema = SchemaParser.parseSingle("{\"type\":\"record\"," + "\"name\":\"NestedLogicalTypesRecord\","
        + "\"namespace\":\"org.apache.avro.codegentest.testdata\","
        + "\"doc\":\"Test nested types with logical types in generated Java classes\"," + "\"fields\":["
        + "{\"name\":\"nestedRecord\"," + "\"type\":{\"type\":\"record\",\"name\":\"NestedRecord\",\"fields\":"
        + "[{\"name\":\"nullableDateField\","
        + "\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]}}]}");

    final Map<String, String> usedCustomLogicalTypeFactories = compiler.getUsedCustomLogicalTypeFactories(schema);
    assertEquals(0, usedCustomLogicalTypeFactories.size());
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypes() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    Schema nullableDecimal1 = Schema.createUnion(Schema.create(Schema.Type.NULL),
        LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES)));
    Schema schemaWithNullableDecimal1 = Schema.createRecord("WithNullableDecimal", "", "", false,
        Collections.singletonList(new Schema.Field("decimal", nullableDecimal1, "", null)));

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schemaWithNullableDecimal1);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.Conversions.DecimalConversion", usedConversionClasses.iterator().next());
  }

  @Test
  void getUsedConversionClassesForNullableTimestamps() throws Exception {
    SpecificCompiler compiler = createCompiler();

    // timestamp-millis and timestamp-micros used to cause collisions when both were
    // present or added as converters (AVRO-2481).
    final Schema tsMillis = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    final Schema tsMicros = LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG));
    final Schema tsNanos = LogicalTypes.timestampNanos().addToSchema(Schema.create(Schema.Type.LONG));

    final Collection<String> conversions = compiler.getUsedConversionClasses(SchemaBuilder.record("WithTimestamps")
        .fields().name("tsMillis").type(tsMillis).noDefault().name("tsMillisOpt").type().unionOf().nullType().and()
        .type(tsMillis).endUnion().noDefault().name("tsMicros").type(tsMicros).noDefault().name("tsMicrosOpt").type()
        .unionOf().nullType().and().type(tsMicros).endUnion().noDefault().name("tsNanos").type(tsNanos).noDefault()
        .name("tsNanosOpt").type().unionOf().nullType().and().type(tsNanos).endUnion().noDefault().endRecord());

    assertEquals(3, conversions.size());
    assertThat(conversions, hasItem("org.apache.avro.data.TimeConversions.TimestampMillisConversion"));
    assertThat(conversions, hasItem("org.apache.avro.data.TimeConversions.TimestampMicrosConversion"));
    assertThat(conversions, hasItem("org.apache.avro.data.TimeConversions.TimestampNanosConversion"));
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypesInNestedRecord() throws Exception {
    SpecificCompiler compiler = createCompiler();

    final Schema schema = SchemaParser.parseSingle(
        "{\"type\":\"record\",\"name\":\"NestedLogicalTypesRecord\",\"namespace\":\"org.apache.avro.codegentest.testdata\",\"doc\":\"Test nested types with logical types in generated Java classes\",\"fields\":[{\"name\":\"nestedRecord\",\"type\":{\"type\":\"record\",\"name\":\"NestedRecord\",\"fields\":[{\"name\":\"nullableDateField\",\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]}}]}");

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schema);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.data.TimeConversions.DateConversion", usedConversionClasses.iterator().next());
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypesInArray() throws Exception {
    SpecificCompiler compiler = createCompiler();

    final Schema schema = SchemaParser.parseSingle(
        "{\"type\":\"record\",\"name\":\"NullableLogicalTypesArray\",\"namespace\":\"org.apache.avro.codegentest.testdata\",\"doc\":\"Test nested types with logical types in generated Java classes\",\"fields\":[{\"name\":\"arrayOfLogicalType\",\"type\":{\"type\":\"array\",\"items\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}}]}");

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schema);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.data.TimeConversions.DateConversion", usedConversionClasses.iterator().next());
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypesInArrayOfRecords() throws Exception {
    SpecificCompiler compiler = createCompiler();

    final Schema schema = SchemaParser.parseSingle(
        "{\"type\":\"record\",\"name\":\"NestedLogicalTypesArray\",\"namespace\":\"org.apache.avro.codegentest.testdata\",\"doc\":\"Test nested types with logical types in generated Java classes\",\"fields\":[{\"name\":\"arrayOfRecords\",\"type\":{\"type\":\"array\",\"items\":{\"type\":\"record\",\"name\":\"RecordInArray\",\"fields\":[{\"name\":\"nullableDateField\",\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]}}}]}");

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schema);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.data.TimeConversions.DateConversion", usedConversionClasses.iterator().next());
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypesInUnionOfRecords() throws Exception {
    SpecificCompiler compiler = createCompiler();

    final Schema schema = SchemaParser.parseSingle(
        "{\"type\":\"record\",\"name\":\"NestedLogicalTypesUnion\",\"namespace\":\"org.apache.avro.codegentest.testdata\",\"doc\":\"Test nested types with logical types in generated Java classes\",\"fields\":[{\"name\":\"unionOfRecords\",\"type\":[\"null\",{\"type\":\"record\",\"name\":\"RecordInUnion\",\"fields\":[{\"name\":\"nullableDateField\",\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]}]}]}");

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schema);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.data.TimeConversions.DateConversion", usedConversionClasses.iterator().next());
  }

  @Test
  void getUsedConversionClassesForNullableLogicalTypesInMapOfRecords() throws Exception {
    SpecificCompiler compiler = createCompiler();

    final Schema schema = SchemaParser.parseSingle(
        "{\"type\":\"record\",\"name\":\"NestedLogicalTypesMap\",\"namespace\":\"org.apache.avro.codegentest.testdata\",\"doc\":\"Test nested types with logical types in generated Java classes\",\"fields\":[{\"name\":\"mapOfRecords\",\"type\":{\"type\":\"map\",\"values\":{\"type\":\"record\",\"name\":\"RecordInMap\",\"fields\":[{\"name\":\"nullableDateField\",\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}]}]},\"avro.java.string\":\"String\"}}]}");

    final Collection<String> usedConversionClasses = compiler.getUsedConversionClasses(schema);
    assertEquals(1, usedConversionClasses.size());
    assertEquals("org.apache.avro.data.TimeConversions.DateConversion", usedConversionClasses.iterator().next());
  }

  /**
   * Checks that identifiers that may cause problems in Java code will compile
   * correctly when used in a generated specific record.
   *
   * @param schema                         A schema with an identifier __test__
   *                                       that will be replaced.
   * @param throwsTypeExceptionOnPrimitive If true, using a reserved word that is
   *                                       also an Avro primitive type name must
   *                                       throw an exception instead of
   *                                       generating code.
   * @param dstDirPrefix                   Where to generate the java code before
   *                                       compiling.
   */
  public void testManglingReservedIdentifiers(String schema, boolean throwsTypeExceptionOnPrimitive,
      String dstDirPrefix) throws IOException {
    Set<String> reservedIdentifiers = new HashSet<>();
    reservedIdentifiers.addAll(SpecificData.RESERVED_WORDS);
    reservedIdentifiers.addAll(SpecificData.TYPE_IDENTIFIER_RESERVED_WORDS);
    reservedIdentifiers.addAll(SpecificData.ACCESSOR_MUTATOR_RESERVED_WORDS);
    reservedIdentifiers.addAll(SpecificData.ERROR_RESERVED_WORDS);
    for (String reserved : reservedIdentifiers) {
      try {
        Schema s = SchemaParser.parseSingle(schema.replace("__test__", reserved));
        assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, dstDirPrefix + "_" + reserved),
            new SpecificCompiler(s).compile());
      } catch (AvroTypeException e) {
        if (!(throwsTypeExceptionOnPrimitive && e.getMessage().contains("Schemas may not be named after primitives")))
          throw e;
      }
    }
  }

  @Test
  void mangleRecordName() throws Exception {
    testManglingReservedIdentifiers(
        SchemaBuilder.record("__test__").fields().requiredInt("field").endRecord().toString(), true,
        "testMangleRecordName");
  }

  @Test
  void mangleRecordNamespace() throws Exception {
    testManglingReservedIdentifiers(
        SchemaBuilder.record("__test__.Record").fields().requiredInt("field").endRecord().toString(), false,
        "testMangleRecordNamespace");
  }

  @Test
  void mangleField() throws Exception {
    testManglingReservedIdentifiers(
        SchemaBuilder.record("Record").fields().requiredInt("__test__").endRecord().toString(), false,
        "testMangleField");
  }

  @Test
  void mangleEnumName() throws Exception {
    testManglingReservedIdentifiers(SchemaBuilder.enumeration("__test__").symbols("reserved").toString(), true,
        "testMangleEnumName");
  }

  @Test
  void mangleEnumSymbol() throws Exception {
    testManglingReservedIdentifiers(SchemaBuilder.enumeration("Enum").symbols("__test__").toString(), false,
        "testMangleEnumSymbol");
  }

  @Test
  void mangleFixedName() throws Exception {
    testManglingReservedIdentifiers(SchemaBuilder.fixed("__test__").size(2).toString(), true, "testMangleFixedName");
  }

  @Test
  void logicalTypesWithMultipleFields() throws Exception {
    Schema logicalTypesWithMultipleFields = new SchemaParser()
        .parse(new File("src/test/resources/logical_types_with_multiple_fields.avsc")).mainSchema();
    assertCompilesWithJavaCompiler(new File(OUTPUT_DIR, "testLogicalTypesWithMultipleFields"),
        new SpecificCompiler(logicalTypesWithMultipleFields).compile(), true);
  }

  @Test
  void unionAndFixedFields() throws Exception {
    Schema unionTypesWithMultipleFields = new SchemaParser()
        .parse(new File("src/test/resources/union_and_fixed_fields.avsc")).mainSchema();
    assertCompilesWithJavaCompiler(new File(this.outputFile, "testUnionAndFixedFields"),
        new SpecificCompiler(unionTypesWithMultipleFields).compile());
  }

  @Test
  void logicalTypesWithMultipleFieldsDateTime() throws Exception {
    Schema logicalTypesWithMultipleFields = new SchemaParser()
        .parse(new File("src/test/resources/logical_types_with_multiple_fields.avsc")).mainSchema();
    assertCompilesWithJavaCompiler(new File(this.outputFile, "testLogicalTypesWithMultipleFieldsDateTime"),
        new SpecificCompiler(logicalTypesWithMultipleFields).compile());
  }

  @Test
  void conversionInstanceWithDecimalLogicalTypeDisabled() throws Exception {
    final SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(false);

    final Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    final Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    final Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    final Schema decimalSchema = LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES));
    final Schema uuidSchema = LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING));

    assertEquals("new org.apache.avro.data.TimeConversions.DateConversion()", compiler.conversionInstance(dateSchema),
        "Should use date conversion for date type");
    assertEquals("new org.apache.avro.data.TimeConversions.TimeMillisConversion()",
        compiler.conversionInstance(timeSchema), "Should use time conversion for time type");
    assertEquals("new org.apache.avro.data.TimeConversions.TimestampMillisConversion()",
        compiler.conversionInstance(timestampSchema), "Should use timestamp conversion for date type");
    assertEquals("null", compiler.conversionInstance(decimalSchema), "Should use null for decimal if the flag is off");
    assertEquals("new org.apache.avro.Conversions.UUIDConversion()", compiler.conversionInstance(uuidSchema),
        "Should use org.apache.avro.Conversions.UUIDConversion() for uuid if the flag is off");
  }

  @Test
  void conversionInstanceWithDecimalLogicalTypeEnabled() throws Exception {
    SpecificCompiler compiler = createCompiler();
    compiler.setEnableDecimalLogicalType(true);

    Schema dateSchema = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT));
    Schema timeSchema = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT));
    Schema timestampSchema = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG));
    Schema decimalSchema = LogicalTypes.decimal(9, 2).addToSchema(Schema.create(Schema.Type.BYTES));
    Schema uuidSchema = LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING));

    assertEquals("new org.apache.avro.data.TimeConversions.DateConversion()", compiler.conversionInstance(dateSchema),
        "Should use date conversion for date type");
    assertEquals("new org.apache.avro.data.TimeConversions.TimeMillisConversion()",
        compiler.conversionInstance(timeSchema), "Should use time conversion for time type");
    assertEquals("new org.apache.avro.data.TimeConversions.TimestampMillisConversion()",
        compiler.conversionInstance(timestampSchema), "Should use timestamp conversion for date type");
    assertEquals("new org.apache.avro.Conversions.DecimalConversion()", compiler.conversionInstance(decimalSchema),
        "Should use null for decimal if the flag is off");
    assertEquals("new org.apache.avro.Conversions.UUIDConversion()", compiler.conversionInstance(uuidSchema),
        "Should use org.apache.avro.Conversions.UUIDConversion() for uuid if the flag is off");
  }

  @Test
  void pojoWithOptionalTurnedOffByDefault() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        assertFalse(line.contains("Optional"));
      }
    }
  }

  @Test
  void pojoWithOptionalCreatedWhenOptionTurnedOn() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setGettersReturnOptional(true);
    // compiler.setCreateOptionalGetters(true);
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    int optionalFound = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.contains("Optional")) {
          optionalFound++;
        }
      }
    }
    assertEquals(9, optionalFound);
  }

  @Test
  void pojoWithOptionalCreateForNullableFieldsWhenOptionTurnedOn() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setGettersReturnOptional(true);
    compiler.setOptionalGettersForNullableFieldsOnly(true);
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    int optionalFound = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.contains("Optional")) {
          optionalFound++;
        }
      }
    }
    assertEquals(5, optionalFound);
  }

  @Test
  void pojoWithOptionalCreatedWhenOptionalForEverythingTurnedOn() throws IOException {
    SpecificCompiler compiler = createCompiler();
    // compiler.setGettersReturnOptional(true);
    compiler.setCreateOptionalGetters(true);
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    int optionalFound = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.contains("Optional")) {
          optionalFound++;
        }
      }
    }
    assertEquals(17, optionalFound);
  }

  @Test
  void pojoWithOptionalOnlyWhenNullableCreatedTurnedOnAndGettersReturnOptionalTurnedOff() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setOptionalGettersForNullableFieldsOnly(true);
    compiler.compileToDestination(this.src, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        // no optionals since gettersReturnOptionalOnlyForNullable is false
        assertFalse(line.contains("Optional"));
      }
    }
  }

  @Test
  void additionalToolsAreInjectedIntoTemplate() throws Exception {
    SpecificCompiler compiler = createCompiler();
    List<Object> customTools = new ArrayList<>();
    customTools.add(new String());
    compiler.setAdditionalVelocityTools(customTools);
    compiler.setTemplateDir("src/test/resources/templates_with_custom_tools/");
    compiler.compileToDestination(this.src, this.OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    int itWorksFound = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.contains("It works!")) {
          itWorksFound++;
        }
      }
    }
    assertEquals(1, itWorksFound);
  }

  @Test
  void pojoWithUUID() throws IOException {
    SpecificCompiler compiler = createCompiler();
    compiler.setOptionalGettersForNullableFieldsOnly(true);
    File avsc = new File("src/main/resources/logical-uuid.avsc");
    compiler.compileToDestination(avsc, OUTPUT_DIR);
    assertTrue(this.outputFile.exists());
    try (BufferedReader reader = new BufferedReader(new FileReader(this.outputFile))) {
      String line;
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.contains("guid")) {
          assertTrue(line.contains("java.util.UUID"));
        }
      }
    }
  }

  public static class StringCustomLogicalTypeFactory implements LogicalTypes.LogicalTypeFactory {
    @Override
    public LogicalType fromSchema(Schema schema) {
      return new LogicalType("string-custom");
    }
  }

  @Test
  void fieldWithUnderscore_avro3826() {
    String jsonSchema = "{\n" + "  \"name\": \"Value\",\n" + "  \"type\": \"record\",\n" + "  \"fields\": [\n"
        + "    { \"name\": \"__deleted\",  \"type\": \"string\"\n" + "    }\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs = new SpecificCompiler(SchemaParser.parseSingle(jsonSchema))
        .compile();
    assertEquals(1, outputs.size());
    SpecificCompiler.OutputFile outputFile = outputs.iterator().next();
    assertTrue(outputFile.contents.contains("getDeleted()"));
    assertFalse(outputFile.contents.contains("$0"));
    assertFalse(outputFile.contents.contains("$1"));

    String jsonSchema2 = "{\n" + "  \"name\": \"Value\",  \"type\": \"record\",\n" + "  \"fields\": [\n"
        + "    { \"name\": \"__deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"_deleted\",  \"type\": \"string\"}\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs2 = new SpecificCompiler(SchemaParser.parseSingle(jsonSchema2))
        .compile();
    assertEquals(1, outputs2.size());
    SpecificCompiler.OutputFile outputFile2 = outputs2.iterator().next();

    assertTrue(outputFile2.contents.contains("getDeleted()"));
    assertTrue(outputFile2.contents.contains("getDeleted$0()"));
    assertFalse(outputFile.contents.contains("$1"));

    String jsonSchema3 = "{\n" + "  \"name\": \"Value\",  \"type\": \"record\",\n" + "  \"fields\": [\n"
        + "    { \"name\": \"__deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"_deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"deleted\",  \"type\": \"string\"}\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs3 = new SpecificCompiler(SchemaParser.parseSingle(jsonSchema3))
        .compile();
    assertEquals(1, outputs3.size());
    SpecificCompiler.OutputFile outputFile3 = outputs3.iterator().next();

    assertTrue(outputFile3.contents.contains("getDeleted()"));
    assertTrue(outputFile3.contents.contains("getDeleted$0()"));
    assertTrue(outputFile3.contents.contains("getDeleted$1()"));
    assertFalse(outputFile3.contents.contains("$2"));

    String jsonSchema4 = "{\n" + "  \"name\": \"Value\",  \"type\": \"record\",\n" + "  \"fields\": [\n"
        + "    { \"name\": \"__deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"_deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"deleted\",  \"type\": \"string\"},\n"
        + "    { \"name\": \"Deleted\",  \"type\": \"string\"}\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs4 = new SpecificCompiler(SchemaParser.parseSingle(jsonSchema4))
        .compile();
    assertEquals(1, outputs4.size());
    SpecificCompiler.OutputFile outputFile4 = outputs4.iterator().next();

    assertTrue(outputFile4.contents.contains("getDeleted()"));
    assertTrue(outputFile4.contents.contains("getDeleted$0()"));
    assertTrue(outputFile4.contents.contains("getDeleted$1()"));
    assertTrue(outputFile4.contents.contains("getDeleted$2()"));
    assertFalse(outputFile4.contents.contains("$3"));
  }

  @Test
  void docsAreEscaped_avro4053() {
    String jsonSchema = "{\n" + "  \"protocol\": \"DummyProtocol\",\n"
        + "  \"doc\": \"*/\\nTest escaping <threats>\\n/*\",\n" + "  \"types\" : [\n"
        + "    {\"type\": \"fixed\", \"name\": \"Hash\", \"size\": 16, \"doc\": \"*/\\nTest escaping <threats>\\n/*\""
        + "},\n"
        + "    {\"type\": \"enum\", \"name\": \"Status\", \"symbols\": [\"ON\", \"OFF\"], \"doc\": \"*/\\nTest escaping <threats>\\n/*\"},\n"
        + "   " + " {\"type\": \"record\", \"name\": \"Message\", \"fields\" : [\n"
        + "      {\"name\": \"content\", \"type\": \"string\", \"doc\":  \"*/\\nTest escaping <threats>\\n/*\"},\n"
        + "      {\"name\": \"status\", \"type\": \"Status\", \"doc\":  \"*/\\nTest escaping <threats>\\n/*\"},\n"
        + "      {\"name\": \"signature\", \"type\": \"Hash\", \"doc\":  \"*/\\nTest escaping <threats>\\n/*\"}\n"
        + "    ]}\n" + "  ],\n" + "  \"messages\" : {\n" + "    \"echo\": {\"request\": ["
        + "{\"name\": \"msg\", \"type\": \"Message\"}"
        + "], \"response\": \"Message\", \"doc\": \"*/\\nTest escaping <threats>\\n/*\"}\n" + "  },\n"
        + "  \"javaAnnotation\": [\n" + "    \"Deprecated(forRemoval = true, since = \\\"forever\\\")\",\n"
        + "    \"SuppressWarnings(\\\"ALL\\\")\",\n" + "    \"SuppressWarnings(\\\"CodeInjection\\\")/*\",\n"
        + "    \" This is inside a comment as each line is prefixed with @\",\n"
        + "    \" and the next bit is really dangerous... */ static { System.exit(); }\"\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs = new SpecificCompiler(Protocol.parse(jsonSchema)).compile();
    for (SpecificCompiler.OutputFile outputFile : outputs) {
      assertFalse(outputFile.contents.contains("*/\\nTest escaping <threats>\\n/*"), "Threats present?");

      int expectedEscapeCount = countOccurrences(Pattern.compile("Test escaping", Pattern.LITERAL),
          outputFile.contents);
      int escapedJavaDocCount = countOccurrences(Pattern.compile("\\*&#47;\\s*Test escaping &lt;threats&gt;\\s*/\\*"),
          outputFile.contents);
      // noinspection RegExpRedundantEscape
      int escapedDocStringCount = countOccurrences(
          Pattern.compile("\\\"doc\\\":\\\"*/\\\\nTest escaping <threats>\\\\n/*\\\"", Pattern.LITERAL),
          outputFile.contents);
      assertEquals(expectedEscapeCount, escapedJavaDocCount + escapedDocStringCount,
          "Escaped threats in " + outputFile.path);

      assertFalse(Pattern.compile("\\{ System.exit\\(\\); }(?!\\\\\")").matcher(outputFile.contents).find(),
          "Code injection present? " + outputFile.contents);
    }
  }

  @Test
  void annotationCannotBreakOutViaStringLiteral() {
    // Security regression test for AVRO-4313.
    //
    // The first javaAnnotation below is an attack. It uses an unescaped quote to
    // "close" the annotation early, then sneaks in real Java code
    // ... static { System.exit(1); } ...
    // before reopening another annotation. If validation is too loose this code
    // gets written straight into the generated .java file and runs when the
    // class is loaded. The compiler must reject it instead of copying it out.
    String jsonSchema = "{\n" + "  \"type\": \"record\",\n" + "  \"name\": \"Injected\",\n"
        + "  \"javaAnnotation\": [\n"
        + "    \"java.lang.SuppressWarnings(\\\"x\\\") static { System.exit(1); } @java.lang.SuppressWarnings(\\\"y\\\")\",\n"
        + "    \"SuppressWarnings(\\\"unchecked\\\")\"\n" + "  ],\n" + "  \"fields\": [\n"
        + "    {\"name\": \"value\", \"type\": \"string\"}\n" + "  ]\n" + "}";
    Collection<SpecificCompiler.OutputFile> outputs = new SpecificCompiler(SchemaParser.parseSingle(jsonSchema))
        .compile();
    boolean validAnnotationEmitted = false;
    for (SpecificCompiler.OutputFile outputFile : outputs) {
      // The schema is also written into the generated file, inside the SCHEMA$
      // string constant, so the attack text does appear there - but safely
      // escaped (every " becomes \"). We only fail if it shows up as real code,
      // i.e. with the original unescaped quotes.
      assertFalse(outputFile.contents.contains("SuppressWarnings(\"x\") static { System.exit(1); }"),
          "Code injection present? " + outputFile.contents);
      validAnnotationEmitted |= outputFile.contents.contains("@SuppressWarnings(\"unchecked\")");
    }
    // A normal annotation sitting next to the attack must still come through, so
    // we know the fix rejects only the bad value, not every annotation.
    assertTrue(validAnnotationEmitted, "Valid annotation missing from generated output");
  }

  @Test
  void unicodeEscapesInDocsAreNeutralized() {
    // The Java compiler decodes Unicode escapes (\ uXXXX) across the whole source
    // file, including inside comments, before comments are recognized (JLS 3.3).
    // A doc value carrying the literal text "\ u002a\ u002f" therefore decodes to
    // "*/" at compile time and could close the generated Javadoc comment early,
    // enabling arbitrary code injection. Since a Unicode escape always requires a
    // literal backslash, escapeForJavadoc neutralizes every backslash, which
    // covers all escape variants at once.
    String[] maliciousDocs = { //
        "\\u002a\\u002f static { System.exit(1); } \\u002f\\u002a", // basic form
        "\\uuuu002a\\uuuu002f System.exit(1);", // multiple 'u's are legal (JLS 3.3)
        "\\u005cu002a\\u005cu002f System.exit(1);", // escape that would decode to a backslash
        "\\U002A\\u002F", // uppercase hex / uppercase-U decoy
        "prefix\\\\u002a\\\\u002f even-backslash-run", // even run of backslashes
        "literal */ static { System.exit(1); } /* comment close", // no escape at all
        "first line\\u002a\\u002f\nsecond line \\u002f\\u002a end" // spans multiple physical lines
    };

    for (String maliciousDoc : maliciousDocs) {
      // Unit-level check on the escaping utility itself.
      String escaped = SpecificCompiler.escapeForJavadoc(maliciousDoc);
      assertFalse(escaped.contains("\\"), "Backslashes must be neutralized: " + escaped);
      assertFalse(escaped.contains("*/"), "Comment terminator must be neutralized: " + escaped);

      // End-to-end check: no raw backslash may reach the generated source outside of
      // string literals. A Java Unicode escape always requires a literal backslash,
      // so the absence of backslashes everywhere except string literals proves no
      // \ uXXXX sequence can be reconstituted by the compiler to close a comment.
      Schema schema = SchemaBuilder.record("EvilRecord").namespace("org.apache.avro.codegentest.testdata")
          .doc(maliciousDoc).fields().name("field").doc(maliciousDoc).type().stringType().noDefault().endRecord();
      Collection<SpecificCompiler.OutputFile> outputs = new SpecificCompiler(schema).compile();
      assertEquals(1, outputs.size());
      for (SpecificCompiler.OutputFile outputFile : outputs) {
        // Remove Java string literals (the schema is embedded via escapeForJavaString,
        // which doubles backslashes and is therefore immune) so that the remaining
        // text is code and comments only. This checks every line of every Javadoc
        // block, including the middle lines of a multi-line doc comment.
        String withoutStringLiterals = removeJavaStringLiterals(outputFile.contents);
        assertFalse(withoutStringLiterals.contains("\\"),
            "Raw backslash reached generated code/comments: " + outputFile.path);
      }
    }
  }

  /**
   * Returns the given Java source with the content of all double-quoted string
   * literals removed, so tests can assert on code and comments without matching
   * the (legitimately backslash-containing) embedded schema string literal.
   */
  private String removeJavaStringLiterals(String source) {
    StringBuilder out = new StringBuilder(source.length());
    boolean inString = false;
    for (int i = 0; i < source.length(); i++) {
      char c = source.charAt(i);
      if (inString) {
        if (c == '\\') {
          i++; // skip the escaped character (e.g. \" or \\)
        } else if (c == '"') {
          inString = false;
        }
      } else if (c == '"') {
        inString = true;
      } else {
        out.append(c);
      }
    }
    return out.toString();
  }

  private int countOccurrences(Pattern pattern, String textToSearch) {
    int count = 0;
    for (Matcher matcher = pattern.matcher(textToSearch); matcher.find();) {
      count++;
    }
    return count;
  }
}
