/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. 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.logstash.launchers;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;



/**
 * Parse jvm.options file applying version conditional logic. Heavily inspired by same functionality in Elasticsearch.
 * */
public class JvmOptionsParser {

    private static final String[] MANDATORY_JVM_OPTIONS = new String[]{
            "-Djruby.regexp.interruptible=true",
            "-Djdk.io.File.enableADS=true",
            "16-:--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
            "16-:--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
            "16-:--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
            "16-:--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
            "16-:--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
            "11-:--add-opens=java.base/java.security=ALL-UNNAMED",
            "11-:--add-opens=java.base/java.io=ALL-UNNAMED",
            "11-:--add-opens=java.base/java.nio.channels=ALL-UNNAMED",
            "11-:--add-opens=java.base/sun.nio.ch=ALL-UNNAMED",
            "11-:--add-opens=java.management/sun.management=ALL-UNNAMED"
    };


    @SuppressWarnings("serial")
    static class JvmOptionsFileParserException extends Exception {

        private static final long serialVersionUID = 2446165130736962758L;

        private final Path jvmOptionsFile;

        Path jvmOptionsFile() {
            return jvmOptionsFile;
        }

        private final SortedMap<Integer, String> invalidLines;

        SortedMap<Integer, String> invalidLines() {
            return invalidLines;
        }

        JvmOptionsFileParserException(final Path jvmOptionsFile, final SortedMap<Integer, String> invalidLines) {
            this.jvmOptionsFile = jvmOptionsFile;
            this.invalidLines = invalidLines;
        }

    }

    private final String logstashHome;

    JvmOptionsParser(String logstashHome) {
        this.logstashHome = logstashHome;
    }

    /**
     * The main entry point. The exit code is 0 if the JVM options were successfully parsed, otherwise the exit code is 1. If an improperly
     * formatted line is discovered, the line is output to standard error.
     *
     * @param args the args to the program which should consist of a single option, the path to LOGSTASH_HOME
     */
    public static void main(final String[] args) {
        if (args.length < 1 || args.length > 2) {
            throw new IllegalArgumentException(
                    "Expected two arguments specifying path to LOGSTASH_HOME and an optional LS_JVM_OPTS, but was " + Arrays.toString(args)
            );
        }
        bailOnOldJava();
        handleJvmOptions(args, System.getenv("LS_JAVA_OPTS"));
    }

    static void bailOnOldJava(){
        if (JavaVersion.CURRENT.compareTo(JavaVersion.JAVA_11) < 0) {
            final String message = String.format(
                    Locale.ROOT,
                    "The minimum required Java version is 11; your Java version from [%s] does not meet this requirement",
                    System.getProperty("java.home")
            );
            System.err.println(message);
            System.exit(1);
        }
    }
    static void handleJvmOptions(String[] args, String lsJavaOpts) {
        final JvmOptionsParser parser = new JvmOptionsParser(args[0]);
        final String jvmOpts = args.length == 2 ? args[1] : null;
        try {
            Optional<Path> jvmOptions = parser.lookupJvmOptionsFile(jvmOpts);
            parser.handleJvmOptions(jvmOptions, lsJavaOpts);
        } catch (JvmOptionsFileParserException pex) {
            System.err.printf(Locale.ROOT,
                    "encountered [%d] error%s parsing [%s]",
                    pex.invalidLines().size(),
                    pex.invalidLines().size() == 1 ? "" : "s",
                    pex.jvmOptionsFile());
            int errorCounter = 0;
            for (final Map.Entry<Integer, String> entry : pex.invalidLines().entrySet()) {
                errorCounter++;
                System.err.printf(Locale.ROOT,
                        "[%d]: encountered improperly formatted JVM option in [%s] on line number [%d]: [%s]",
                        errorCounter,
                        pex.jvmOptionsFile(),
                        entry.getKey(),
                        entry.getValue());
            }
        } catch (IOException ex) {
            System.err.println("Error accessing jvm.options file");
            System.exit(1);
        }
    }

    private Optional<Path> lookupJvmOptionsFile(String jvmOpts) {
        if (jvmOpts != null && !jvmOpts.isEmpty()) {
            return Optional.of(Paths.get(jvmOpts));
        }
        // Set the initial JVM options from the jvm.options file. Look in
        // /etc/logstash first, and break if that file is found readable there.
        return Arrays.stream(new Path[] { Paths.get("/etc/logstash/jvm.options"),
                                          Paths.get(logstashHome, "config/jvm.options") })
                .filter(p -> p.toFile().canRead())
                .findFirst();
    }

    private void handleJvmOptions(Optional<Path> jvmOptionsFile, String lsJavaOpts) throws IOException, JvmOptionsFileParserException {
        int javaMajorVersion = javaMajorVersion();

        // Add JVM Options from config/jvm.options
        final Set<String> jvmOptionsContent = new LinkedHashSet<>(getJvmOptionsFromFile(jvmOptionsFile, javaMajorVersion));

        // Add JVM Options from LS_JAVA_OPTS
        if (lsJavaOpts != null && !lsJavaOpts.isEmpty()) {
            if (isDebugEnabled()) {
                System.err.println("Appending jvm options from environment LS_JAVA_OPTS");
            }
            Arrays.stream(lsJavaOpts.split(" "))
                    .filter(s -> !s.isBlank())
                    .forEach(jvmOptionsContent::add);
        }
        // Set mandatory JVM options
        jvmOptionsContent.addAll(getMandatoryJvmOptions(javaMajorVersion));

        final Set<String> jvmFinalOptions = nettyMaxOrderDefaultTo11(jvmOptionsContent);

        System.out.println(String.join(" ", jvmFinalOptions));
    }

    /**
     * Inplace method that verifies if Netty's maxOrder option is already set, else configure it to have
     * the default value of 11.
     *
     * @param options the collection of options to examine.
     * @return the collection of input option eventually with Netty maxOrder added.
     * */
    private Set<String> nettyMaxOrderDefaultTo11(Set<String> options) {
        boolean maxOrderAlreadyContained = options.stream().anyMatch(s -> s.startsWith("-Dio.netty.allocator.maxOrder"));
        if (maxOrderAlreadyContained) {
            return options;
        }
        // Order is important because LS_JAVA_OPTS is added last and must take precedence 
        // over settings in jvm.options
        final Set<String> acc = new LinkedHashSet<>(options);
        acc.add("-Dio.netty.allocator.maxOrder=11");
        return acc;
    }

    /**
     * Returns the list of mandatory JVM options for the given version of Java.
     * @param javaMajorVersion
     * @return Collection of mandatory options
     */
     static Collection<String> getMandatoryJvmOptions(int javaMajorVersion){
          return Arrays.stream(MANDATORY_JVM_OPTIONS)
                  .map(option -> jvmOptionFromLine(javaMajorVersion, option))
                  .flatMap(Optional::stream)
                  .collect(Collectors.toUnmodifiableList());
    }

    private List<String> getJvmOptionsFromFile(final Optional<Path> jvmOptionsFile, final int javaMajorVersion) throws IOException, JvmOptionsFileParserException {
        if (!jvmOptionsFile.isPresent()) {
            System.err.println("Warning: no jvm.options file found.");
            return Collections.emptyList();
        }
        final Path optionsFilePath = jvmOptionsFile.get();
        if (!optionsFilePath.toFile().exists()) {
            System.err.format("Warning: jvm.options file does not exist or is not readable: `%s`\n", optionsFilePath);
            return Collections.emptyList();
        }

        if (isDebugEnabled()) {
            System.err.format("Processing jvm.options file at `%s`\n", optionsFilePath);
        }
        try (InputStream is = Files.newInputStream(optionsFilePath);
             Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8);
             BufferedReader br = new BufferedReader(reader)
        ) {
            final ParseResult parseResults = parse(javaMajorVersion, br);
            if (parseResults.hasErrors()) {
                throw new JvmOptionsFileParserException(optionsFilePath, parseResults.getInvalidLines());
            }
            return parseResults.getJvmOptions();
        }
    }

    private boolean isDebugEnabled() {
        final String debug = System.getenv("DEBUG");
        if (debug == null) {
            return false;
        }

        return "1".equals(debug) || Boolean.parseBoolean(debug);
    }

    /**
     * Collector of parsed lines and errors.
     * */
    static final class ParseResult {
        private final List<String> jvmOptions = new ArrayList<>();
        private final SortedMap<Integer, String> invalidLines = new TreeMap<>();

        void appendOption(String option) {
            jvmOptions.add(option);
        }

        void appendError(int lineNumber, String malformedLine) {
            invalidLines.put(lineNumber, malformedLine);
        }

        public boolean hasErrors() {
            return !invalidLines.isEmpty();
        }

        public SortedMap<Integer, String> getInvalidLines() {
            return invalidLines;
        }

        public List<String> getJvmOptions() {
            return jvmOptions;
        }
    }

    private static final Pattern OPTION_DEFINITION = Pattern.compile("((?<start>\\d+)(?<range>-)?(?<end>\\d+)?:)?(?<option>-.*)$");

    /**
     *
     * If the version syntax specified on a line matches the specified JVM options, the JVM option callback will be invoked with the JVM
     * option. If the line does not match the specified syntax for the JVM options, the invalid line callback will be invoked with the
     * contents of the entire line.
     *
     * @param javaMajorVersion the Java major version to match JVM options against
     * @param br the buffered reader to read line-delimited JVM options from
     * @return the admitted options lines respecting the javaMajorVersion and the error lines
     * @throws IOException if an I/O exception occurs reading from the buffered reader
     */
    static ParseResult parse(final int javaMajorVersion, final BufferedReader br) throws IOException {
        final ParseResult result = new ParseResult();
        int lineNumber = 0;
        while (true) {
            final String line = br.readLine();
            lineNumber++;
            if (line == null) {
                break;
            }
            try{
                jvmOptionFromLine(javaMajorVersion, line).ifPresent(result::appendOption);
            } catch (IllegalArgumentException e){
                result.appendError(lineNumber, line);
            };
        }
        return result;
    }

    /**
     * Parse the line-delimited JVM options from the specified string for the specified Java major version.
     * Valid JVM options are:
     * <ul>
     *     <li>
     *         a line starting with a dash is treated as a JVM option that applies to all versions
     *     </li>
     *     <li>
     *         a line starting with a number followed by a colon is treated as a JVM option that applies to the matching Java major version
     *         only
     *     </li>
     *     <li>
     *         a line starting with a number followed by a dash followed by a colon is treated as a JVM option that applies to the matching
     *         Java specified major version and all larger Java major versions
     *     </li>
     *     <li>
     *         a line starting with a number followed by a dash followed by a number followed by a colon is treated as a JVM option that
     *         applies to the specified range of matching Java major versions
     *     </li>
     * </ul>
     *
     * For example, if the specified Java major version is 8, the following JVM options will be accepted:
     * <ul>
     *     <li>
     *         {@code -XX:+PrintGCDateStamps}
     *     </li>
     *     <li>
     *         {@code 8:-XX:+PrintGCDateStamps}
     *     </li>
     *     <li>
     *         {@code 8-:-XX:+PrintGCDateStamps}
     *     </li>
     *     <li>
     *         {@code 7-8:-XX:+PrintGCDateStamps}
     *     </li>
     * </ul>
     * and the following JVM options will be skipped:
     * <ul>
     *     <li>
     *         {@code 9:-Xlog:age*=trace,gc*,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m}
     *     </li>
     *     <li>
     *         {@code 9-:-Xlog:age*=trace,gc*,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m}
     *     </li>
     *     <li>
     *         {@code 9-10:-Xlog:age*=trace,gc*,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m}
     *     </li>
     * </ul>

     * @param javaMajorVersion
     * @param line
     * @return Returns an Optional containing a string if the line contains a valid option for the specified Java
     *         version and empty otherwise
     */
    private static Optional<String> jvmOptionFromLine(final int javaMajorVersion, final String line){
        if (line.startsWith("#") || line.matches("\\s*")) {
            // Skip comments and blank lines
            return Optional.empty();
        }
        final Matcher matcher = OPTION_DEFINITION.matcher(line);
        if (matcher.matches()) {
            final String start = matcher.group("start");
            final String end = matcher.group("end");
            if (start == null) {
                // no range present, unconditionally apply the JVM option
                return Optional.of(line);
            } else {
                final int lower = Integer.parseInt(start);
                final int upper;
                if (matcher.group("range") == null) {
                    // no range is present, apply the JVM option to the specified major version only
                    upper = lower;
                } else if (end == null) {
                    // a range of the form \\d+- is present, apply the JVM option to all major versions larger than the specified one
                    upper = Integer.MAX_VALUE;
                } else {
                        upper = Integer.parseInt(end);
                    if (upper < lower) {
                        throw new IllegalArgumentException("Upper bound must be greater than lower bound");
                    }
                }
                if (lower <= javaMajorVersion && javaMajorVersion <= upper) {
                    return Optional.of(matcher.group("option"));
                }
            }
        } else {
            throw new IllegalArgumentException("Illegal JVM Option");
        }
        return Optional.empty();
    }

    private static final Pattern JAVA_VERSION = Pattern.compile("^(?:1\\.)?(?<javaMajorVersion>\\d+)(?:\\.\\d+)?$");

    private static int javaMajorVersion() {
        final String specVersion = System.getProperty("java.specification.version");
        final Matcher specVersionMatcher = JAVA_VERSION.matcher(specVersion);
        if (!specVersionMatcher.matches()) {
            throw new IllegalStateException(String.format("Failed to extract Java major version from specification `%s`", specVersion));
        }
        return Integer.parseInt(specVersionMatcher.group("javaMajorVersion"));
    }
}
