package io.vertx.codetrans;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import io.vertx.codetrans.annotations.CodeTranslate;
import io.vertx.codetrans.lang.groovy.GroovyLang;
import io.vertx.codetrans.lang.js.JavaScriptLang;
import io.vertx.codetrans.lang.kotlin.KotlinLang;
import io.vertx.codetrans.lang.ruby.RubyLang;
import io.vertx.codetrans.lang.scala.ScalaLang;
import io.vertx.core.Verticle;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.TypeMirror;
import javax.tools.FileObject;
import javax.tools.StandardLocation;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * A processor plugin generate scripts from {@link io.vertx.core.Verticle} class. It scans all the compiled
 * classes and tries to generate corresponding scripts for each class.<p/>
 * <p>
 * The script is named after the verticle fqn using the last atom of the package name and the lower
 * cased class name, for example : {@code examples.http.Server} maps to {@code http/server.js},
 * {@code http/server.groovy}, etc...<p/>
 * <p>
 * The processor is only active when the option {@code codetrans.output} is set to a valid directory where the scripts
 * will be written. A log <i>codetrans.log</i> will also be written with the processor activity.
 * <p>
 * The processor can be configured using the {@code condetrans.config} property targeting a JSON file. The JSON file
 * contains a set of exclusions and is structured as follows:
 * <p>
 * <code><pre>
 *     {
 *       "excludes": [
 *        {
 *          "package" : "the (java) package to exclude",
 *          "langs" : ["lang1", "lang2"]
 *        }
 *       ]
 *     }
 * </pre></code>
 * <p>
 * The {@code package} element is mandatory. {@code Langs} is optional. When not set, all languages are skipped.
 * Languages are identified by their <em>extensions</em>.
 *
 * @author <a href="mailto:julien@julienviet.com">Julien Viet</a>
 * @author <a href="mailto:clement@apache.org">Clement Escoffier</a>
 */
public class CodeTransProcessor extends AbstractProcessor {


  private File outputDir;
  private CodeTranslator translator;
  private List<Lang> langs;
  private Set<File> folders = new HashSet<>(); // The copied folders so we don't do the job twice
  private PrintWriter log;
  private ObjectNode config;
  private Map<String, Set<String>> abc = new HashMap<>();
  private RenderMode renderMode;

  @Override
  public Set<String> getSupportedOptions() {
    return Collections.singleton("codetrans.output");
  }

  @Override
  public Set<String> getSupportedAnnotationTypes() {
    return Collections.singleton("*");
  }

  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    String outputOption = processingEnv.getOptions().get("codetrans.output");
    if (outputOption != null) {
      outputDir = new File(outputOption);
    }
    translator = new CodeTranslator(processingEnv);
    langs = new ArrayList<>();
    String renderOpt = processingEnv.getOptions().get("codetrans.render");
    renderMode = renderOpt != null ? RenderMode.valueOf(renderOpt.toUpperCase()) : RenderMode.EXAMPLE;
    String langsOpt = processingEnv.getOptions().get("codetrans.langs");
    Set<String> langs;
    if (langsOpt != null) {
      langs = new HashSet<>(Arrays.asList(langsOpt.split("\\s*,\\s*")));
    } else {
      langs = new HashSet<>(Arrays.asList("js", "ruby", "kotlin", "groovy"));
    }
    String configOpt = processingEnv.getOptions().get("codetrans.config");
    if (configOpt != null) {
      ObjectMapper mapper = new ObjectMapper()
          .enable(JsonParser.Feature.ALLOW_COMMENTS)
          .enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES);
      File file = new File(configOpt);
      try {
        config = (ObjectNode) mapper.readTree(file);
      } catch (IOException e) {
        System.err.println("[ERROR] Cannot read configuration file " + file.getAbsolutePath() + " : " + e.getMessage());
        e.printStackTrace();
      }
    }
    for (String lang : langs) {
      Lang l;
      switch (lang) {
        case "kotlin":
          l = new KotlinLang();
          break;
        case "groovy":
          l = new GroovyLang();
          break;
        case "js":
          l = new JavaScriptLang();
          break;
        case "scala":
          l = new ScalaLang();
          break;
        case "ruby":
          l = new RubyLang();
          break;
        default:
          continue;
      }
      this.langs.add(l);
      if (config != null) {
        JsonNode n = config.get(lang);
        if (n != null && n.getNodeType() == JsonNodeType.OBJECT) {
          JsonNode excludes = n.get("excludes");
          if (excludes != null && excludes.getNodeType() == JsonNodeType.ARRAY) {
            Set<String> t = new HashSet<>();
            abc.put(l.id(), t);
            for (int i = 0;i < excludes.size();i++) {
              JsonNode c = excludes.get(i);
              if (c.getNodeType() == JsonNodeType.STRING) {
                TextNode tn = (TextNode) c;
                t.add(tn.asText());
              }
            }
          }
        }
      }
    }
  }

  private PrintWriter getLogger() throws Exception {
    if (log == null) {
      log = new PrintWriter(new FileWriter(new File(outputDir, "codetrans.log"), false), true);
    }
    return log;
  }

  private void copyDirRec(File srcFolder, File dstFolder, PrintWriter log) throws Exception {
    if (!folders.contains(dstFolder)) {
      folders.add(dstFolder);
      Path srcPath = srcFolder.toPath();
      Path dstPath = dstFolder.toPath();
      SimpleFileVisitor<Path> copyingVisitor = new SimpleFileVisitor<Path>() {
        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
          Path targetPath = dstPath.resolve(srcPath.relativize(dir));
          if (!Files.exists(targetPath)) {
            log.println("Creating dir " + targetPath);
            Files.createDirectory(targetPath);
          }
          return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult visitFile(Path srcFile, BasicFileAttributes attrs) throws IOException {
          if (!srcFile.getFileName().toString().endsWith(".java")) {
            log.println("Copying resource " + srcFile + " to " + dstPath);
            Path dstFile = dstPath.resolve(srcPath.relativize(srcFile));
            Files.copy(srcFile, dstFile, StandardCopyOption.REPLACE_EXISTING);
          }
          return FileVisitResult.CONTINUE;
        }
      };
      Files.walkFileTree(srcPath, copyingVisitor);
    }
  }

  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    if (roundEnv.processingOver()) {
      if (log != null) {
        log.close();
      }
      return false;
    }
    if (outputDir != null && (outputDir.exists() || outputDir.mkdirs())) {
      LinkedHashMap<ExecutableElement, Boolean> methods = new LinkedHashMap<>();
      try {
        PrintWriter log = getLogger();

        // Process all verticles automatically
        TypeMirror verticleType = processingEnv.getElementUtils().getTypeElement(Verticle.class.getName()).asType();
        for (Element rootElt : roundEnv.getRootElements()) {
          Set<Modifier> modifiers = rootElt.getModifiers();
          if (rootElt.getKind() == ElementKind.CLASS &&
              !modifiers.contains(Modifier.ABSTRACT) &&
              modifiers.contains(Modifier.PUBLIC) &&
              processingEnv.getTypeUtils().isSubtype(rootElt.asType(), verticleType)) {
            TypeElement typeElt = (TypeElement) rootElt;
            for (Element enclosedElt : typeElt.getEnclosedElements()) {
              if (enclosedElt.getKind() == ElementKind.METHOD) {
                ExecutableElement methodElt = (ExecutableElement) enclosedElt;
                if (methodElt.getSimpleName().toString().equals("start") && methodElt.getParameters().isEmpty()) {
                  methods.put(methodElt, true);
                }
              }
            }
          }
        }

        // Process CodeTranslate annotations
        roundEnv.getElementsAnnotatedWith(CodeTranslate.class).forEach(annotatedElt -> {
          methods.put((ExecutableElement) annotatedElt, false);
        });

        // Generate
        for (Map.Entry<ExecutableElement, Boolean> method : methods.entrySet()) {
          ExecutableElement methodElt = method.getKey();
          boolean isVerticle = method.getValue();
          TypeElement typeElt = (TypeElement) methodElt.getEnclosingElement();
          FileObject obj = processingEnv.getFiler().getResource(StandardLocation.SOURCE_PATH, "", typeElt.getQualifiedName().toString().replace('.', '/') + ".java");
          File srcFolder = new File(obj.toUri()).getParentFile();
          for (Lang lang : langs) {
            if (isSkipped(typeElt, lang) || isSkipped(methodElt, lang)) {
              log.write("Skipping " + lang.id() + " translation for " + typeElt.getQualifiedName() + "#" +
                  methodElt.getSimpleName());
              continue;
            }
            List<String> fqn = Arrays.asList(typeElt.toString().split("\\."));
            File dstFolder = new File(outputDir, lang.id());
            File f = lang.createSourceFile(dstFolder, fqn, !isVerticle ? methodElt.getSimpleName().toString() : null);
            if (f.getParentFile().exists() || f.getParentFile().mkdirs()) {
              try {
                String translation = translator.translate(methodElt, isVerticle, lang, renderMode);
                Files.write(f.toPath(), translation.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                log.println("Generated " + f.getAbsolutePath());
                copyDirRec(srcFolder, f.getParentFile(), log);
              } catch (Exception e) {
                log.println("Skipping generation of " + typeElt.getQualifiedName());
                e.printStackTrace(log);
              }
            }
          }
        }
      } catch (Exception e) {
        e.printStackTrace();
      }
      return true;
    } else {
      return false;
    }
  }

  private boolean isSkipped(ExecutableElement methodElt, Lang lang) {
    Set<String> excl = abc.get(lang.id());
    if (excl != null) {
      TypeElement typeElt = (TypeElement) methodElt.getEnclosingElement();
      String match = "" + typeElt.getQualifiedName();
      if (excl.contains(match)) {
        return true;
      }
      match += "#" + methodElt.getSimpleName();
      if (excl.contains(match)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Checks whether the generation of the given class to the given lang is explicitly excluded. Exclusions are
   * managed in the configuration file. If no configuration file is provided, the translation is not skipped.
   *
   * @param type the type
   * @param lang the language
   * @return {@code true} if the translation is skipped, {@code false} otherwise.
   */
  private boolean isSkipped(TypeElement type, Lang lang) {
    if (config == null) {
      // no config, no exclusions
      return false;
    }
    ArrayNode excludes = (ArrayNode) config.get("excludes");
    for (JsonNode exclude : excludes) {
      // Structure:
      // {
      //   "package": "the package to exclude", (mandatory)
      //   "langs": ["lang 1", "lang 2"]
      // }
      // If not langs - skip all languages

      if (exclude.get("package") == null) {
        throw new IllegalStateException("Malformed configuration - Missing 'package' attribute in the 'codetrans" +
            ".config' file");
      }

      String pck = exclude.get("package").asText();
      ArrayNode langs = (ArrayNode) exclude.get("langs");
      if (type.getQualifiedName().toString().startsWith(pck) && isLanguageSkipped(langs, lang)) {
        return true;
      }
    }
    return false;
  }

  private boolean isLanguageSkipped(ArrayNode langs, Lang lang) {
    if (langs == null) {
      // If not langs, exclude all.
      return true;
    }
    for (JsonNode node : langs) {
      if (node.asText().equalsIgnoreCase(lang.getExtension())) {
        return true;
      }
    }
    return false;
  }
}
