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

import org.apache.avro.LogicalTypes;
import org.apache.avro.Protocol;
import org.apache.avro.Schema;
import org.apache.avro.compiler.specific.SpecificCompiler;
import org.apache.avro.generic.GenericData;
import org.apache.maven.artifact.DependencyResolutionRequiredException;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
import org.apache.maven.shared.model.fileset.FileSet;
import org.apache.maven.shared.model.fileset.util.FileSetManager;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;

/**
 * Base for Avro Compiler Mojos.
 */
public abstract class AbstractAvroMojo extends AbstractMojo {
  /**
   * The source directory of avro files. This directory is added to the classpath
   * at schema compiling time. All files can therefore be referenced as classpath
   * resources following the directory structure under the source directory.
   *
   * @parameter property="sourceDirectory"
   *            default-value="${basedir}/src/main/avro"
   */
  private File sourceDirectory;

  /**
   * @parameter property="outputDirectory"
   *            default-value="${project.build.directory}/generated-sources/avro"
   */
  private File outputDirectory;

  /**
   * @parameter property="sourceDirectory"
   *            default-value="${basedir}/src/test/avro"
   */
  private File testSourceDirectory;

  /**
   * @parameter property="outputDirectory"
   *            default-value="${project.build.directory}/generated-test-sources/avro"
   */
  private File testOutputDirectory;

  /**
   * The field visibility indicator for the fields of the generated class, as
   * string values of SpecificCompiler.FieldVisibility. The text is case
   * insensitive.
   *
   * @parameter default-value="PRIVATE"
   */
  private String fieldVisibility;

  /**
   * A list of files or directories that should be compiled first thus making them
   * importable by subsequently compiled schemas. Note that imported files should
   * not reference each other.
   *
   * @parameter
   */
  protected String[] imports;

  /**
   * A set of Ant-like exclusion patterns used to prevent certain files from being
   * processed. By default, this set is empty such that no files are excluded.
   *
   * @parameter
   */
  protected String[] excludes = new String[0];

  /**
   * A set of Ant-like exclusion patterns used to prevent certain files from being
   * processed. By default, this set is empty such that no files are excluded.
   *
   * @parameter
   */
  protected String[] testExcludes = new String[0];

  /**
   * The Java type to use for Avro strings. May be one of CharSequence, String or
   * Utf8. CharSequence by default.
   *
   * @parameter property="stringType"
   */
  protected String stringType = "CharSequence";

  /**
   * The directory (within the java classpath) that contains the velocity
   * templates to use for code generation. The default value points to the
   * templates included with the avro-maven-plugin.
   *
   * @parameter property="templateDirectory"
   */
  protected String templateDirectory = "/org/apache/avro/compiler/specific/templates/java/classic/";

  /**
   * The qualified names of classes which the plugin will look up, instantiate
   * (through an empty constructor that must exist) and set up to be injected into
   * Velocity templates by Avro compiler.
   *
   * @parameter property="velocityToolsClassesNames"
   */
  protected String[] velocityToolsClassesNames = new String[0];

  /**
   * Generated record schema classes will extend this class.
   *
   * @parameter property="recordSpecificClass"
   */
  private String recordSpecificClass = "org.apache.avro.specific.SpecificRecordBase";

  /**
   * Generated error schema classes will extend this class.
   *
   * @parameter property="errorSpecificClass"
   */
  private String errorSpecificClass = "org.apache.avro.specific.SpecificExceptionBase";

  /**
   * The createOptionalGetters parameter enables generating the getOptional...
   * methods that return an Optional of the requested type. This works ONLY on
   * Java 8+
   *
   * @parameter property="createOptionalGetters"
   */
  protected boolean createOptionalGetters = false;

  /**
   * The gettersReturnOptional parameter enables generating get... methods that
   * return an Optional of the requested type. This works ONLY on Java 8+
   *
   * @parameter property="gettersReturnOptional"
   */
  protected boolean gettersReturnOptional = false;

  /**
   * The optionalGettersForNullableFieldsOnly parameter works in conjunction with
   * gettersReturnOptional option. If it is set, Optional getters will be
   * generated only for fields that are nullable. If the field is mandatory,
   * regular getter will be generated. This works ONLY on Java 8+.
   *
   * @parameter property="optionalGettersForNullableFieldsOnly"
   */
  protected boolean optionalGettersForNullableFieldsOnly = false;

  /**
   * Determines whether or not to create setters for the fields of the record. The
   * default is to create setters.
   *
   * @parameter default-value="true"
   */
  protected boolean createSetters;

  /**
   * If set to true, {@literal @}Nullable and {@literal @}NotNull annotations are
   * added to fields of the record. The default is false. If enabled, JetBrains
   * annotations are used by default but other annotations can be specified via
   * the nullSafeAnnotationNullable and nullSafeAnnotationNotNull parameters.
   *
   * @parameter property="createNullSafeAnnotations"
   *
   * @see <a href=
   *      "https://www.jetbrains.com/help/idea/annotating-source-code.html#nullability-annotations">
   *      JetBrains nullability annotations</a>
   */
  protected boolean createNullSafeAnnotations = false;

  /**
   * Controls which annotation should be added to nullable fields if
   * createNullSafeAnnotations is enabled. The default is
   * org.jetbrains.annotations.Nullable.
   *
   * @parameter property="nullSafeAnnotationNullable"
   *
   * @see <a href=
   *      "https://www.jetbrains.com/help/idea/annotating-source-code.html#nullability-annotations">
   *      JetBrains nullability annotations</a>
   */
  protected String nullSafeAnnotationNullable = "org.jetbrains.annotations.Nullable";

  /**
   * Controls which annotation should be added to non-nullable fields if
   * createNullSafeAnnotations is enabled. The default is
   * org.jetbrains.annotations.NotNull.
   *
   * @parameter property="nullSafeAnnotationNotNull"
   *
   * @see <a href=
   *      "https://www.jetbrains.com/help/idea/annotating-source-code.html#nullability-annotations">
   *      JetBrains nullability annotations</a>
   */
  protected String nullSafeAnnotationNotNull = "org.jetbrains.annotations.NotNull";

  /**
   * A set of fully qualified class names of custom
   * {@link org.apache.avro.Conversion} implementations to add to the compiler.
   * The classes must be on the classpath at compile time and whenever the Java
   * objects are serialized.
   *
   * @parameter property="customConversions"
   */
  protected String[] customConversions = new String[0];

  /**
   * A set of fully qualified class names of custom
   * {@link org.apache.avro.LogicalTypes.LogicalTypeFactory} implementations to
   * add to the compiler. The classes must be on the classpath at compile time and
   * whenever the Java objects are serialized.
   *
   * @parameter property="customLogicalTypeFactories"
   */
  protected String[] customLogicalTypeFactories = new String[0];

  /**
   * Determines whether or not to use Java classes for decimal types
   *
   * @parameter default-value="false"
   */
  protected boolean enableDecimalLogicalType;

  /**
   * The current Maven project.
   *
   * @parameter default-value="${project}"
   * @readonly
   * @required
   */
  protected MavenProject project;

  @Override
  public void execute() throws MojoExecutionException {
    boolean hasSourceDir = null != sourceDirectory && sourceDirectory.isDirectory();
    boolean hasImports = null != imports;
    boolean hasTestDir = null != testSourceDirectory && testSourceDirectory.isDirectory();
    if (!hasSourceDir && !hasTestDir) {
      throw new MojoExecutionException("neither sourceDirectory: " + sourceDirectory + " or testSourceDirectory: "
          + testSourceDirectory + " are directories");
    }

    if (hasImports) {
      checkImportPaths();
      for (String importedFile : imports) {
        File file = new File(importedFile);
        if (file.isDirectory()) {
          String[] includedFiles = getIncludedFiles(file.getAbsolutePath(), excludes, getIncludes());
          getLog().info("Importing Directory: " + file.getAbsolutePath());
          getLog().debug("Importing Directory Files: " + Arrays.toString(includedFiles));
          compileFiles(includedFiles, file, outputDirectory);
        } else if (file.isFile()) {
          getLog().info("Importing File: " + file.getAbsolutePath());
          compileFiles(new String[] { file.getName() }, file.getParentFile(), outputDirectory);
        }
      }
    }

    if (hasSourceDir) {
      String[] includedFiles = getIncludedFiles(sourceDirectory.getAbsolutePath(), excludes, getIncludes());
      compileFiles(includedFiles, sourceDirectory, outputDirectory);
    }

    if (hasImports || hasSourceDir) {
      project.addCompileSourceRoot(outputDirectory.getAbsolutePath());
    }

    if (hasTestDir) {
      String[] includedFiles = getIncludedFiles(testSourceDirectory.getAbsolutePath(), testExcludes, getTestIncludes());
      compileFiles(includedFiles, testSourceDirectory, testOutputDirectory);
      project.addTestCompileSourceRoot(testOutputDirectory.getAbsolutePath());
    }
  }

  private void checkImportPaths() throws MojoExecutionException {
    for (String importedFile : imports) {
      File file = new File(importedFile);
      if (!file.exists()) {
        throw new MojoExecutionException("Path " + file.getAbsolutePath() + " does not exist");
      }
    }
  }

  private String[] getIncludedFiles(String absPath, String[] excludes, String[] includes) {
    final FileSetManager fileSetManager = new FileSetManager();
    final FileSet fs = new FileSet();
    fs.setDirectory(absPath);
    fs.setFollowSymlinks(false);

    // exclude imports directory since it has already been compiled.
    if (imports != null) {
      String importExclude = null;

      for (String importFile : this.imports) {
        File file = new File(importFile);

        if (file.isDirectory()) {
          importExclude = file.getName() + "/**";
        } else if (file.isFile()) {
          importExclude = "**/" + file.getName();
        }

        fs.addExclude(importExclude);
      }
    }
    for (String include : includes) {
      fs.addInclude(include);
    }
    for (String exclude : excludes) {
      fs.addExclude(exclude);
    }
    return fileSetManager.getIncludedFiles(fs);
  }

  private void compileFiles(String[] files, File sourceDir, File outDir) throws MojoExecutionException {
    final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
    try {
      Thread.currentThread().setContextClassLoader(createClassLoader());

      // Need to register custom logical type factories before schema compilation.
      try {
        loadLogicalTypesFactories();
      } catch (IOException e) {
        throw new MojoExecutionException("Error while loading logical types factories ", e);
      }
      this.doCompile(files, sourceDir, outDir);
    } catch (MalformedURLException | DependencyResolutionRequiredException e) {
      throw new MojoExecutionException("Cannot locate classpath entries", e);
    } finally {
      Thread.currentThread().setContextClassLoader(contextClassLoader);
    }
  }

  private void loadLogicalTypesFactories() throws IOException, MojoExecutionException {
    try (URLClassLoader classLoader = createClassLoader()) {
      for (String factory : customLogicalTypeFactories) {
        Class<LogicalTypes.LogicalTypeFactory> logicalTypeFactoryClass = (Class<LogicalTypes.LogicalTypeFactory>) classLoader
            .loadClass(factory);
        LogicalTypes.LogicalTypeFactory factoryInstance = logicalTypeFactoryClass.getDeclaredConstructor()
            .newInstance();
        LogicalTypes.register(factoryInstance);
      }
    } catch (DependencyResolutionRequiredException | ClassNotFoundException e) {
      throw new IOException(e);
    } catch (InstantiationException | InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
      throw new MojoExecutionException("Failed to instantiate logical type factory class", e);
    }
  }

  protected SpecificCompiler.FieldVisibility getFieldVisibility() {
    try {
      String upper = String.valueOf(this.fieldVisibility).trim().toUpperCase();
      return SpecificCompiler.FieldVisibility.valueOf(upper);
    } catch (IllegalArgumentException e) {
      return SpecificCompiler.FieldVisibility.PRIVATE;
    }
  }

  protected List<Object> instantiateAdditionalVelocityTools() {
    final List<Object> velocityTools = new ArrayList<>(velocityToolsClassesNames.length);
    for (String velocityToolClassName : velocityToolsClassesNames) {
      try {
        Class<?> klass = Class.forName(velocityToolClassName);
        velocityTools.add(klass.getDeclaredConstructor().newInstance());
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
    return velocityTools;
  }

  protected void doCompile(String[] files, File sourceDirectory, File outputDirectory) throws MojoExecutionException {
    for (String filename : files) {
      try {
        doCompile(filename, sourceDirectory, outputDirectory);
      } catch (IOException e) {
        throw new MojoExecutionException("Error compiling file " + filename + " to " + outputDirectory, e);
      }
    }
  }

  protected void doCompile(String filename, File sourceDirectory, File outputDirectory) throws IOException {
    throw new UnsupportedOperationException(
        "Programmer error: AbstractAvroMojo.doCompile(String, java.io.File, java.io.File) called directly");
  };

  protected void doCompile(File sourceFileForModificationDetection, Collection<Schema> schemas, File outputDirectory)
      throws IOException {
    doCompile(sourceFileForModificationDetection, new SpecificCompiler(schemas), outputDirectory);
  }

  protected void doCompile(File sourceFileForModificationDetection, Protocol protocol, File outputDirectory)
      throws IOException {
    doCompile(sourceFileForModificationDetection, new SpecificCompiler(protocol), outputDirectory);
  }

  protected void setCompilerProperties(SpecificCompiler compiler) {
    compiler.setTemplateDir(templateDirectory);
    compiler.setStringType(GenericData.StringType.valueOf(stringType));
    compiler.setFieldVisibility(getFieldVisibility());
    compiler.setCreateOptionalGetters(createOptionalGetters);
    compiler.setGettersReturnOptional(gettersReturnOptional);
    compiler.setOptionalGettersForNullableFieldsOnly(optionalGettersForNullableFieldsOnly);
    compiler.setCreateSetters(createSetters);
    compiler.setCreateNullSafeAnnotations(createNullSafeAnnotations);
    compiler.setNullSafeAnnotationNullable(nullSafeAnnotationNullable);
    compiler.setNullSafeAnnotationNotNull(nullSafeAnnotationNotNull);
    compiler.setEnableDecimalLogicalType(enableDecimalLogicalType);
    compiler.setOutputCharacterEncoding(project.getProperties().getProperty("project.build.sourceEncoding"));
    compiler.setAdditionalVelocityTools(instantiateAdditionalVelocityTools());
    compiler.setRecordSpecificClass(this.recordSpecificClass);
    compiler.setErrorSpecificClass(this.errorSpecificClass);
  }

  private void doCompile(File sourceFileForModificationDetection, SpecificCompiler compiler, File outputDirectory)
      throws IOException {
    setCompilerProperties(compiler);
    try {
      for (String customConversion : customConversions) {
        compiler.addCustomConversion(Thread.currentThread().getContextClassLoader().loadClass(customConversion));
      }
    } catch (ClassNotFoundException e) {
      throw new IOException(e);
    }
    compiler.compileToDestination(sourceFileForModificationDetection, outputDirectory);
  }

  protected List<URL> findClasspath() throws DependencyResolutionRequiredException, MalformedURLException {
    final List<URL> urls = appendElements(project.getRuntimeClasspathElements());
    urls.addAll(appendElements(project.getTestClasspathElements()));
    return urls;
  }

  protected URLClassLoader createClassLoader() throws DependencyResolutionRequiredException, MalformedURLException {
    final List<URL> urls = findClasspath();
    return new URLClassLoader(urls.toArray(new URL[0]), Thread.currentThread().getContextClassLoader());
  }

  private List<URL> appendElements(List<String> runtimeClasspathElements) throws MalformedURLException {
    if (runtimeClasspathElements == null) {
      return new ArrayList<>();
    }
    List<URL> runtimeUrls = new ArrayList<>(runtimeClasspathElements.size());
    for (String runtimeClasspathElement : runtimeClasspathElements) {
      runtimeUrls.add(new File(runtimeClasspathElement).toURI().toURL());
    }
    return runtimeUrls;
  }

  protected abstract String[] getIncludes();

  protected abstract String[] getTestIncludes();

}
