/*
 * Copyright 2022, Gerwin Klein, Régis Décamps
 * SPDX-License-Identifier: BSD-3-Clause
 */

package jflex.maven.plugin.cup;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.regex.Pattern;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;

/** Creates a Java parser from CUP definition, using CUP. */
@Mojo(name = "generate", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = false)
public class GenerateMojo extends AbstractMojo {

  /** In a CUP definition, the Java package is introduce by the {@code package} keyword. */
  private static final String PACKAGE_DEFINITION = "package";

  private static final String DEFAULT_JAVA_PACKAGE = "";

  // TODO(regisd) Use java convention for class name.
  // Investigate whether we can change the default class names to more conventional
  // names such as "Parser" and "Symbols".
  /** Default class name of the parser. Note that CUP uses a lower-case class name. */
  static final String DEFAULT_PARSER_NAME = "parser";
  /** Default class name of symbols holder. Note that CUP uses a lower-case class name. */
  static final String DEFAULT_SYMBOLS_NAME = "sym";

  /** Source directory of the cup files. */
  @Parameter(defaultValue = "${project.basedir}/src/main/cup")
  File cupSourceDirectory;

  /** Regular expression of the cup files in the {@link #cupSourceDirectory}. */
  @Parameter(defaultValue = ".*\\.cup")
  String cupSourceFilesFilter;

  /** Name of the directory into which CUP should generate the parser. */
  @Parameter(defaultValue = "${project.build.directory}/generated-sources/cup")
  @SuppressWarnings("WeakerAccess")
  File generatedSourcesDirectory;

  /**
   * Whether to output the symbol constant code as an {@code interface} rather than as a {@code
   * class}.
   */
  @Parameter(defaultValue = "false")
  boolean symbolInterface;

  @Parameter(defaultValue = DEFAULT_SYMBOLS_NAME)
  String symbolsName;

  @Parameter(defaultValue = DEFAULT_PARSER_NAME)
  String parserName;

  @Parameter(property = "project", required = true, readonly = true)
  MavenProject mavenProject;

  /** Whether to force generation of parser and symbols. */
  @Parameter(defaultValue = "false")
  private boolean force;

  private CliCupInvoker cupInvoker;
  private final Logger log;

  @SuppressWarnings("WeakerAccess")
  public GenerateMojo() {
    log = new Logger(getLog());
    this.cupInvoker = new CliCupInvoker(getLog());
  }

  @VisibleForTesting
  GenerateMojo(CliCupInvoker cupInvoker, Log logger) {
    log = new Logger(logger);
    this.cupInvoker = cupInvoker;
  }

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    File[] srcs = getSources();
    for (File f : srcs) {
      try {
        generateParser(f);
      } catch (IOException e) {
        throw new MojoExecutionException("Could not generate parser for " + f.getPath(), e);
      }
    }
  }

  /**
   * @param cupFile CUP definition file
   */
  @VisibleForTesting
  void generateParser(File cupFile) throws IOException, MojoExecutionException {
    String javaPackage = findJavaPackage(cupFile);

    // compiling the generated source in target/generated-sources/cup is
    // the whole point of this plugin.
    mavenProject.addCompileSourceRoot(generatedSourcesDirectory.getPath());

    boolean skipGeneration = !force && !isGeneratedCodeOutdated(cupFile, javaPackage);
    if (skipGeneration) {
      // do nothing.
      log.i("Do nothing. Generated code for is up to date for: %s", cupFile.getName());
      return;
    } else if (force) {
      log.i("Generation requested by force for: %s", cupFile.getName());
    }
    try {
      log.d("Generate CUP parser for %s", cupFile.getAbsolutePath());
      File outputDirectory = JavaUtils.directory(generatedSourcesDirectory, javaPackage);
      log.d("CUP output directory: %s", outputDirectory);
      if (!outputDirectory.exists()) {
        if (!outputDirectory.mkdirs()) {
          throw new IOException("Could not create " + outputDirectory);
        }
      }

      cupInvoker.invoke(
          javaPackage,
          outputDirectory,
          parserName,
          symbolsName,
          symbolInterface,
          cupFile.getAbsolutePath());
      log.i("CUP generated %s and %s for %s", parserName, symbolsName, cupFile.getPath());
    } catch (Exception e) {
      throw new MojoExecutionException(
          "CUP failed to generate parser for " + cupFile.getAbsolutePath(), e);
    }
  }

  private boolean isGeneratedCodeOutdated(File cupFile, String javaPackage) {
    File parserFile = JavaUtils.file(generatedSourcesDirectory, javaPackage, parserName);
    File symFile = JavaUtils.file(generatedSourcesDirectory, javaPackage, symbolsName);
    if (parserFile.lastModified() <= cupFile.lastModified()) {
      log.d("Parser file for %s is not actual: %s", cupFile.getName(), parserFile);
      return true;
    } else {
      log.d("Parser file for %s is actual: %s", cupFile.getName(), parserFile);
    }

    if (symFile.lastModified() <= cupFile.lastModified()) {
      log.d("Symbol file for %s is not actual: %s", symFile.getName(), symFile);
      return true;
    } else {
      log.d("Symbol file for %s is actual: %s", symFile.getName(), symFile);
    }
    return false;
  }

  private String findJavaPackage(File cupFile) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(cupFile))) {
      while (br.ready()) {
        String line = br.readLine();
        Optional<String> optJavaPackage = optionalJavaPackage(line);
        if (optJavaPackage.isPresent()) {
          return optJavaPackage.get();
        }
      }
    }
    return DEFAULT_JAVA_PACKAGE;
  }

  @VisibleForTesting
  Optional<String> optionalJavaPackage(String line) {
    if (line.startsWith(PACKAGE_DEFINITION)) {
      int endOfLine = line.indexOf(";");
      if (endOfLine > -1) {
        String javaPackage = line.substring(PACKAGE_DEFINITION.length(), endOfLine);
        javaPackage = javaPackage.trim();
        return Optional.of(javaPackage);
      }
    }
    return Optional.absent();
  }

  /**
   * Returns the cup source files.
   *
   * @return the files in the {@link #cupSourceDirectory} directory that match {@link
   *     #cupSourceFilesFilter}.
   */
  private File[] getSources() throws MojoFailureException {
    File defaultDir = getAbsolutePath(cupSourceDirectory);
    if (defaultDir == null) {
      throw new MojoFailureException("Expected " + cupSourceDirectory + " to be a directory");
    }
    if (!defaultDir.isDirectory()) {
      throw new MojoFailureException(
          "Expected " + defaultDir.getAbsolutePath() + " to be a directory");
    }
    Pattern regexp = Pattern.compile(cupSourceFilesFilter);
    return defaultDir.listFiles((dir, name) -> regexp.matcher(name).matches());
  }

  /**
   * Converts the specified path argument into an absolute path. If the path is relative like
   * "src/main/jflex", it is resolved against the base directory of the mavenProject (in constrast,
   * File.getAbsoluteFile() would resolve against the current directory which may be different,
   * especially during a reactor build).
   *
   * @param path The path argument to convert, may be {@code null}.
   * @return The absolute path corresponding to the input argument.
   */
  private File getAbsolutePath(File path) {
    if (path == null || path.isAbsolute()) {
      return path;
    }
    return new File(this.mavenProject.getBasedir().getAbsolutePath(), path.getPath());
  }
}
