/*
 *  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
 *
 *    http://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.codehaus.groovy.tools;

import groovy.lang.GroovySystem;
import org.codehaus.groovy.control.CompilationUnit;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.ConfigurationException;
import org.codehaus.groovy.runtime.DefaultGroovyStaticMethods;
import org.codehaus.groovy.runtime.StringGroovyMethods;
import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.IVersionProvider;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParseResult;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static groovy.ui.GroovyMain.buildConfigScriptText;
import static groovy.ui.GroovyMain.processConfigScriptText;
import static groovy.ui.GroovyMain.processConfigScripts;

/**
 * Command-line compiler (aka. <tt>groovyc</tt>).
 */
public class FileSystemCompiler {

    private static boolean displayStackTraceOnError = false;
    private final CompilationUnit unit;

    public FileSystemCompiler(CompilerConfiguration configuration) throws ConfigurationException {
        this(configuration, null);
    }

    public FileSystemCompiler(CompilerConfiguration configuration, CompilationUnit cu) throws ConfigurationException {
        if (cu != null) {
            unit = cu;
        } else if (configuration.getJointCompilationOptions() != null) {
            unit = new JavaAwareCompilationUnit(configuration);
        } else {
            unit = new CompilationUnit(configuration);
        }
    }

    /**
     * Prints the usage help message for {@link CompilationOptions} to stderr.
     *
     * @see #displayHelp(PrintWriter)
     * @since 2.5
     */
    public static void displayHelp() {
        displayHelp(new PrintWriter(System.err, true));
    }

    /**
     * Prints the usage help message for the {@link CompilationOptions} to the specified PrintWriter.
     *
     * @since 2.5
     */
    public static void displayHelp(PrintWriter writer) {
        configureParser(new CompilationOptions()).usage(writer);
    }

    /**
     * Prints version information to stderr.
     *
     * @see #displayVersion(PrintWriter)
     */
    public static void displayVersion() {
        displayVersion(new PrintWriter(System.err, true));
    }

    /**
     * Prints version information to the specified PrintWriter.
     *
     * @since 2.5
     */
    public static void displayVersion(PrintWriter writer) {
        for (String line : new VersionProvider().getVersion()) {
            writer.println(line);
        }
    }

    public static int checkFiles(String[] filenames) {
        int errors = 0;

        for (String filename : filenames) {
            File file = new File(filename);
            if (!file.exists()) {
                System.err.println("error: file not found: " + file);
                errors += 1;
            } else if (!file.canRead()) {
                System.err.println("error: file not readable: " + file);
                errors += 1;
            }
        }

        return errors;
    }

    public static boolean validateFiles(String[] filenames) {
        return checkFiles(filenames) == 0;
    }

    /**
     * Same as main(args) except that exceptions are thrown out instead of causing
     * the VM to exit.
     */
    public static void commandLineCompile(String[] args) throws Exception {
        commandLineCompile(args, true);
    }

    /**
     * Same as main(args) except that exceptions are thrown out instead of causing
     * the VM to exit and the lookup for .groovy files can be controlled
     */
    public static void commandLineCompile(String[] args, boolean lookupUnnamedFiles) throws Exception {
        CompilationOptions options = new CompilationOptions();
        CommandLine parser = configureParser(options);
        ParseResult parseResult = parser.parseArgs(args);
        if (CommandLine.printHelpIfRequested(parseResult)) {
            return;
        }
        displayStackTraceOnError = options.printStack;
        CompilerConfiguration configuration = options.toCompilerConfiguration();

        // Load the file name list
        String[] filenames = options.generateFileNames();
        boolean fileNameErrors = filenames == null;
        if (!fileNameErrors && (filenames.length == 0)) {
            parser.usage(System.err);
            return;
        }

        fileNameErrors = fileNameErrors && !validateFiles(filenames);

        if (!fileNameErrors) {
            doCompilation(configuration, null, filenames, lookupUnnamedFiles);
        }
    }

    public static CommandLine configureParser(CompilationOptions options) {
        CommandLine parser = new CommandLine(options);
        parser.getCommandSpec().parser()
                .unmatchedArgumentsAllowed(true)
                .unmatchedOptionsArePositionalParams(true)
                .expandAtFiles(false)
                .toggleBooleanFlags(false);
        return parser;
    }

    /**
     * Primary entry point for compiling from the command line
     * (using the groovyc script).
     * <p>
     * If calling inside a process and you don't want the JVM to exit on an
     * error call commandLineCompile(String[]), which this method simply wraps
     *
     * @param args command line arguments
     */
    public static void main(String[] args) {
        commandLineCompileWithErrorHandling(args, true);
    }

    /**
     * Primary entry point for compiling from the command line
     * (using the groovyc script).
     * <p>
     * If calling inside a process and you don't want the JVM to exit on an
     * error call commandLineCompile(String[]), which this method simply wraps
     *
     * @param args               command line arguments
     * @param lookupUnnamedFiles do a lookup for .groovy files not part of
     *                           the given list of files to compile
     */
    public static void commandLineCompileWithErrorHandling(String[] args, boolean lookupUnnamedFiles) {
        try {
            commandLineCompile(args, lookupUnnamedFiles);
        } catch (Throwable e) {
            new ErrorReporter(e, displayStackTraceOnError).write(System.err);
            System.exit(1);
        }
    }

    public static void doCompilation(CompilerConfiguration configuration, CompilationUnit unit, String[] filenames) throws Exception {
        doCompilation(configuration, unit, filenames, true);
    }

    public static void doCompilation(CompilerConfiguration configuration, CompilationUnit unit, String[] filenames, boolean lookupUnnamedFiles) throws Exception {
        File tmpDir = null;
        // if there are any joint compilation options set stubDir if not set
        try {
            if (configuration.getJointCompilationOptions() != null
                    && !configuration.getJointCompilationOptions().containsKey("stubDir")) {
                tmpDir = DefaultGroovyStaticMethods.createTempDir(null, "groovy-generated-", "-java-source");
                configuration.getJointCompilationOptions().put("stubDir", tmpDir);
            }

            FileSystemCompiler compiler = new FileSystemCompiler(configuration, unit);

            if (lookupUnnamedFiles) {
                for (String filename : filenames) {
                    File file = new File(filename);
                    if (file.isFile()) {
                        URL url = file.getAbsoluteFile().getParentFile().toURI().toURL();
                        compiler.unit.getClassLoader().addURL(url);
                    }
                }
            } else {
                compiler.unit.getClassLoader()
                        .setResourceLoader(filename -> null);
            }
            compiler.compile(filenames);
        } finally {
            try {
                if (tmpDir != null) deleteRecursive(tmpDir);
            } catch (Throwable t) {
                System.err.println("error: could not delete temp files - " + tmpDir.getPath());
            }
        }
    }

    private static String[] generateFileNamesFromOptions(List<String> filenames) {
        if (filenames == null) {
            return new String[0];
        }

        List<String> fileList = new ArrayList<>(filenames.size());

        boolean errors = false;

        for (String filename : filenames) {
            if (filename.startsWith("@")) {
                String fn = filename.substring(1);
                BufferedReader br = null;

                try {
                    br = new BufferedReader(new FileReader(fn));
                    for (String file; (file = br.readLine()) != null; ) {
                        fileList.add(file);
                    }
                } catch (IOException ioe) {
                    System.err.println("error: file not readable: " + fn);
                    errors = true;
                } finally {
                    if (null != br) {
                        try {
                            br.close();
                        } catch (IOException e) {
                            System.err.println("error: failed to close buffered reader: " + fn);
                            errors = true;
                        }
                    }
                }
            } else {
                fileList.add(filename);
            }
        }

        if (errors) {
            return null;
        } else {
            return fileList.toArray(new String[0]);
        }
    }

    public static void deleteRecursive(File file) {
        if (!file.exists()) {
            return;
        }
        if (file.isFile()) {
            file.delete();
        } else if (file.isDirectory()) {
            File[] files = file.listFiles();
            for (File value : files) {
                deleteRecursive(value);
            }
            file.delete();
        }
    }

    public void compile(String[] paths) throws Exception {
        unit.addSources(paths);
        unit.compile();
    }

    public void compile(File[] files) throws Exception {
        unit.addSources(files);
        unit.compile();
    }

    /**
     * @since 2.5
     */
    public static class VersionProvider implements IVersionProvider {
        @Override
        public String[] getVersion() {
            return new String[]{
                    "Groovy compiler version " + GroovySystem.getVersion(),
                    "Copyright 2003-2021 The Apache Software Foundation. http://groovy-lang.org/",
                    "",
            };
        }
    }

    /**
     * @since 2.5
     */
    @Command(name = "groovyc",
            customSynopsis = "groovyc [options] <source-files>",
            sortOptions = false,
            versionProvider = VersionProvider.class)
    public static class CompilationOptions {
        // IMPLEMENTATION NOTE:
        // classpath must be the first argument, so that the `startGroovy(.bat)` script
        // can extract it and the JVM can be started with the classpath already correctly set.
        // This saves us from having to fork a new JVM process with the classpath set from the processed arguments.
        @Option(names = {"-cp", "-classpath", "--classpath"}, paramLabel = "<path>", description = "Specify where to find the class files - must be first argument")
        private String classpath;

        @Option(names = {"-sourcepath", "--sourcepath"}, paramLabel = "<path>", description = "Specify where to find the source files")
        private File sourcepath;

        @Option(names = {"--temp"}, paramLabel = "<temp>", description = "Specify temporary directory")
        private File temp;

        @Option(names = {"--encoding"}, description = "Specify the encoding of the user class files")
        private String encoding;

        @Option(names = "-d", paramLabel = "<dir>", description = "Specify where to place generated class files")
        private File targetDir;

        @Option(names = {"-e", "--exception"}, description = "Print stack trace on error")
        private boolean printStack;

        @Option(names = {"-pa", "--parameters"}, description = "Generate metadata for reflection on method parameter names (jdk8+ only)")
        private boolean parameterMetadata;

        @Option(names = {"-pr", "--enable-preview"}, description = "Enable preview Java features (JEP 12) (jdk12+ only) - must be after classpath but before other arguments")
        private boolean previewFeatures;

        @Option(names = {"-j", "--jointCompilation"}, description = "Attach javac compiler to compile .java files")
        private boolean jointCompilation;

        @Option(names = {"-b", "--basescript"}, paramLabel = "<class>", description = "Base class name for scripts (must derive from Script)")
        private String scriptBaseClass;

        @Option(names = "-J", paramLabel = "<property=value>", description = "Name-value pairs to pass to javac")
        private Map<String, String> javacOptionsMap;

        @Option(names = "-F", paramLabel = "<flag>", description = "Passed to javac for joint compilation")
        private List<String> flags;

        @Option(names = {"--configscript"}, paramLabel = "<script>", description = "A script for tweaking the configuration options")
        private String configScript;

        @Option(names = {"-h", "--help"}, usageHelp = true, description = "Show this help message and exit")
        private boolean helpRequested;

        @Option(names = {"-v", "--version"}, versionHelp = true, description = "Print version information and exit")
        private boolean versionRequested;

        @Parameters(description = "The groovy source files to compile, or @-files containing a list of source files to compile",
                paramLabel = "<source-files>")
        private List<String> files;

        @Option(names = {"--compile-static"}, description = "Use CompileStatic")
        private boolean compileStatic;

        @Option(names = {"--type-checked"}, description = "Use TypeChecked")
        private boolean typeChecked;

        public CompilerConfiguration toCompilerConfiguration() throws IOException {
            // Setup the configuration data
            CompilerConfiguration configuration = new CompilerConfiguration();

            if (classpath != null) {
                configuration.setClasspath(classpath);
            }

            if (targetDir != null && targetDir.getName().length() > 0) {
                configuration.setTargetDirectory(targetDir);
            }

            configuration.setParameters(parameterMetadata);
            configuration.setPreviewFeatures(previewFeatures);
            configuration.setSourceEncoding(encoding);
            configuration.setScriptBaseClass(scriptBaseClass);

            // joint compilation parameters
            if (jointCompilation) {
                Map<String, Object> compilerOptions = new HashMap<>();
                compilerOptions.put("flags", javacFlags());
                compilerOptions.put("namedValues", javacNamedValues());
                configuration.setJointCompilationOptions(compilerOptions);
            }

            final List<String> transformations = new ArrayList<>();
            if (compileStatic) {
                transformations.add("ast(groovy.transform.CompileStatic)");
            }
            if (typeChecked) {
                transformations.add("ast(groovy.transform.TypeChecked)");
            }
            if (!transformations.isEmpty()) {
                processConfigScriptText(buildConfigScriptText(transformations), configuration);
            }

            String configScripts = System.getProperty("groovy.starter.configscripts", null);
            if (configScript != null || (configScripts != null && !configScripts.isEmpty())) {
                List<String> scripts = new ArrayList<>();
                if (configScript != null) {
                    scripts.add(configScript);
                }
                if (configScripts != null) {
                    scripts.addAll(StringGroovyMethods.tokenize(configScripts, ','));
                }
                processConfigScripts(scripts, configuration);
            }

            return configuration;
        }

        public String[] generateFileNames() {
            return generateFileNamesFromOptions(files);
        }

        private String[] javacNamedValues() {
            List<String> result = new ArrayList<>();
            if (javacOptionsMap != null) {
                for (Map.Entry<String, String> entry : javacOptionsMap.entrySet()) {
                    result.add(entry.getKey());
                    result.add(entry.getValue());
                }
            }
            return result.isEmpty() ? null : result.toArray(new String[0]);
        }

        private String[] javacFlags() {
            List<String> result = new ArrayList<>();
            if (flags != null) {
                result.addAll(flags);
            }
            if (parameterMetadata) {
                result.add("parameters");
            }
            if (previewFeatures) {
                result.add("-enable-preview");
            }
            return result.isEmpty() ? null : result.toArray(new String[0]);
        }
    }
}
