/*
 *    Copyright 2009-2022 the original author or authors.
 *
 *    Licensed 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 com.googlecode.htmlcompressor.compressor;

import com.google.common.io.ByteStreams;
import com.google.javascript.jscomp.CompilationLevel;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerOptions;
import com.google.javascript.jscomp.Result;
import com.google.javascript.jscomp.SourceFile;
import com.google.javascript.jscomp.WarningLevel;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Basic JavaScript compressor implementation using <a href="http://code.google.com/closure/compiler/">Google Closure
 * Compiler</a> that could be used by {@link HtmlCompressor} for inline JavaScript compression.
 *
 * @author <a href="mailto:serg472@gmail.com">Sergiy Kovalchuk</a>
 *
 * @see HtmlCompressor#setJavaScriptCompressor(Compressor)
 * @see <a href="http://code.google.com/closure/compiler/">Google Closure Compiler</a>
 */
public class ClosureJavaScriptCompressor implements Compressor {

    /** The constant LOGGER. */
    private static final Logger logger = LoggerFactory.getLogger(ClosureJavaScriptCompressor.class);

    /** The Constant COMPILATION_LEVEL_SIMPLE. */
    public static final String COMPILATION_LEVEL_SIMPLE = "simple";

    /** The Constant COMPILATION_LEVEL_ADVANCED. */
    public static final String COMPILATION_LEVEL_ADVANCED = "advanced";

    /** The Constant COMPILATION_LEVEL_WHITESPACE. */
    public static final String COMPILATION_LEVEL_WHITESPACE = "whitespace";

    // Closure compiler default settings

    /** The compiler options. */
    private CompilerOptions compilerOptions = new CompilerOptions();

    /** The compilation level. */
    private CompilationLevel compilationLevel = CompilationLevel.SIMPLE_OPTIMIZATIONS;

    /** The logging level (note: closure compiler still uses java util logging, don't import Level. */
    private java.util.logging.Level loggingLevel = java.util.logging.Level.SEVERE;

    /** The warning level. */
    private WarningLevel warningLevel = WarningLevel.DEFAULT;

    /** The custom externs only. */
    private boolean customExternsOnly;

    /** The externs. */
    private List<SourceFile> externs;

    /**
     * Instantiates a new closure java script compressor.
     */
    public ClosureJavaScriptCompressor() {
        // Required for override.
    }

    /**
     * Instantiates a new closure java script compressor.
     *
     * @param compilationLevel
     *            the compilation level
     */
    public ClosureJavaScriptCompressor(CompilationLevel compilationLevel) {
        this.compilationLevel = compilationLevel;
    }

    @Override
    public String compress(String source) {

        StringWriter writer = new StringWriter();

        // prepare source
        List<SourceFile> input = new ArrayList<>();
        input.add(SourceFile.fromCode("source.js", source));

        // prepare externs
        List<SourceFile> externsList = new ArrayList<>();
        if (compilationLevel.equals(CompilationLevel.ADVANCED_OPTIMIZATIONS)) {
            // default externs
            if (!customExternsOnly) {
                try {
                    externsList = getDefaultExterns();
                } catch (IOException e) {
                    logger.error("", e);
                }
            }
            // add user defined externs
            if (externs != null) {
                for (SourceFile extern : externs) {
                    externsList.add(extern);
                }
            }
            // add empty externs
            if (externsList.isEmpty()) {
                externsList.add(SourceFile.fromCode("externs.js", ""));
            }
        } else {
            // empty externs
            externsList.add(SourceFile.fromCode("externs.js", ""));
        }

        Compiler.setLoggingLevel(loggingLevel);

        Compiler compiler = new Compiler();
        compiler.disableThreads();

        compilationLevel.setOptionsForCompilationLevel(compilerOptions);
        warningLevel.setOptionsForWarningLevel(compilerOptions);

        Result result = compiler.compile(externsList, input, compilerOptions);

        if (result.success) {
            writer.write(compiler.toSource());
        } else {
            writer.write(source);
        }

        return writer.toString();

    }

    /**
     * Gets the default externs.
     *
     * @return the default externs
     *
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    // read default externs from closure.jar
    private List<SourceFile> getDefaultExterns() throws IOException {
        InputStream input = ClosureJavaScriptCompressor.class.getResourceAsStream("/externs.zip");
        List<SourceFile> externList = new ArrayList<>();
        try (ZipInputStream zip = new ZipInputStream(input)) {
            for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) {
                externList.add(SourceFile.builder().withCharset(Charset.defaultCharset())
                        .withContent(ByteStreams.limit(zip, entry.getSize())).withPath(entry.getName()).build());
            }
        }
        return externList;
    }

    /**
     * Returns level of optimization that is applied when compiling JavaScript code.
     *
     * @return <code>CompilationLevel</code> that is applied when compiling JavaScript code.
     *
     * @see <a href=
     *      "http://closure-compiler.googlecode.com/svn/trunk/javadoc/com/google/javascript/jscomp/CompilationLevel.html">CompilationLevel</a>
     */
    public CompilationLevel getCompilationLevel() {
        return compilationLevel;
    }

    /**
     * Sets level of optimization that should be applied when compiling JavaScript code. If none is provided,
     * <code>CompilationLevel.SIMPLE_OPTIMIZATIONS</code> will be used by default.
     * <p>
     * <b>Warning:</b> Using <code>CompilationLevel.ADVANCED_OPTIMIZATIONS</code> could break inline JavaScript if
     * externs are not set properly.
     *
     * @param compilationLevel
     *            Optimization level to use, could be set to <code>CompilationLevel.ADVANCED_OPTIMIZATIONS</code>,
     *            <code>CompilationLevel.SIMPLE_OPTIMIZATIONS</code>, <code>CompilationLevel.WHITESPACE_ONLY</code>
     *
     * @see <a href="http://code.google.com/closure/compiler/docs/api-tutorial3.html">Advanced Compilation and
     *      Externs</a>
     * @see <a href="http://code.google.com/closure/compiler/docs/compilation_levels.html">Closure Compiler Compilation
     *      Levels</a>
     * @see <a href=
     *      "http://closure-compiler.googlecode.com/svn/trunk/javadoc/com/google/javascript/jscomp/CompilationLevel.html">CompilationLevel</a>
     */
    public void setCompilationLevel(CompilationLevel compilationLevel) {
        this.compilationLevel = compilationLevel;
    }

    /**
     * Returns options that are used by the Closure compiler.
     *
     * @return <code>CompilerOptions</code> that are used by the compiler
     *
     * @see <a href=
     *      "http://closure-compiler.googlecode.com/svn/trunk/javadoc/com/google/javascript/jscomp/CompilerOptions.html">CompilerOptions</a>
     */
    public CompilerOptions getCompilerOptions() {
        return compilerOptions;
    }

    /**
     * Sets options that will be used by the Closure compiler. If none is provided, default options constructor will be
     * used: <code>new CompilerOptions()</code>.
     *
     * @param compilerOptions
     *            <code>CompilerOptions</code> that will be used by the compiler
     *
     * @see <a href=
     *      "http://closure-compiler.googlecode.com/svn/trunk/javadoc/com/google/javascript/jscomp/CompilerOptions.html">CompilerOptions</a>
     */
    public void setCompilerOptions(CompilerOptions compilerOptions) {
        this.compilerOptions = compilerOptions;
    }

    /**
     * Returns logging level used by the Closure compiler (note: closure compiler still uses java util logging, don't
     * import Level).
     *
     * @return <code>Level</code> of logging used by the Closure compiler
     */
    public java.util.logging.Level getLoggingLevel() {
        return loggingLevel;
    }

    /**
     * Sets logging level for the Closure compiler (note: closure compiler still uses java util logging, don't import
     * Level).
     *
     * @param loggingLevel
     *            logging level for the Closure compiler.
     *
     * @see java.util.logging.Level
     */
    public void setLoggingLevel(java.util.logging.Level loggingLevel) {
        this.loggingLevel = loggingLevel;
    }

    /**
     * Returns <code>SourceFile</code> used as a reference during the compression at
     * <code>CompilationLevel.ADVANCED_OPTIMIZATIONS</code> level.
     *
     * @return <code>SourceFile</code> used as a reference during compression
     */
    public List<SourceFile> getExterns() {
        return externs;
    }

    /**
     * Sets external JavaScript files that are used as a reference for function declarations if
     * <code>CompilationLevel.ADVANCED_OPTIMIZATIONS</code> compression level is used.
     * <p>
     * A number of default externs defined inside Closure's jar will be used besides user defined ones, to use only user
     * defined externs set {@link #setCustomExternsOnly(boolean) setCustomExternsOnly(true)}
     * <p>
     * <b>Warning:</b> Using <code>CompilationLevel.ADVANCED_OPTIMIZATIONS</code> could break inline JavaScript if
     * externs are not set properly.
     *
     * @param externs
     *            <code>SourceFile</code> to use as a reference during compression
     *
     * @see #setCompilationLevel(CompilationLevel)
     * @see #setCustomExternsOnly(boolean)
     * @see <a href="http://code.google.com/closure/compiler/docs/api-tutorial3.html">Advanced Compilation and
     *      Externs</a>
     * @see <a href=
     *      "http://closure-compiler.googlecode.com/svn/trunk/javadoc/com/google/javascript/jscomp/SourceFile.html">SourceFile</a>
     */
    public void setExterns(List<SourceFile> externs) {
        this.externs = externs;
    }

    /**
     * Returns <code>WarningLevel</code> used by the Closure compiler.
     *
     * @return <code>WarningLevel</code> used by the Closure compiler
     */
    public WarningLevel getWarningLevel() {
        return warningLevel;
    }

    /**
     * Indicates the amount of information you want from the compiler about possible problems in your code.
     *
     * @param warningLevel
     *            <code>WarningLevel</code> to use
     *
     * @see <a href="http://code.google.com/closure/compiler/docs/api-ref.html">Google Closure Compiler</a>
     */
    public void setWarningLevel(WarningLevel warningLevel) {
        this.warningLevel = warningLevel;
    }

    /**
     * Returns <code>true</code> if default externs defined inside Closure's jar are ignored and only user defined ones
     * are used.
     *
     * @return <code>true</code> if default externs defined inside Closure's jar are ignored and only user defined ones
     *         are used
     */
    public boolean isCustomExternsOnly() {
        return customExternsOnly;
    }

    /**
     * If set to <code>true</code>, default externs defined inside Closure's jar will be ignored and only user defined
     * ones will be used.
     *
     * @param customExternsOnly
     *            <code>true</code> to skip default externs and use only user defined ones
     *
     * @see #setExterns(List)
     * @see #setCompilationLevel(CompilationLevel)
     */
    public void setCustomExternsOnly(boolean customExternsOnly) {
        this.customExternsOnly = customExternsOnly;
    }

}
