package com.strobel.decompiler;

import com.beust.jcommander.JCommander;
import com.strobel.annotations.NotNull;
import com.strobel.assembler.InputTypeLoader;
import com.strobel.assembler.metadata.*;
import com.strobel.core.ExceptionUtilities;
import com.strobel.core.StringUtilities;
import com.strobel.decompiler.LineNumberFormatter.LineNumberOption;
import com.strobel.decompiler.languages.BytecodeLanguage;
import com.strobel.decompiler.languages.Languages;
import com.strobel.decompiler.languages.LineNumberPosition;
import com.strobel.decompiler.languages.TypeDecompilationResults;
import com.strobel.decompiler.languages.java.JavaFormattingOptions;
import com.strobel.io.PathHelper;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.Charset;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.logging.ConsoleHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

public class DecompilerDriver {
    public static void main(final String[] args) {
        final CommandLineOptions options = new CommandLineOptions();
        final JCommander jCommander;
        final List<String> typeNames;

        try {
            jCommander = new JCommander(options);
            jCommander.setAllowAbbreviatedOptions(true);
            jCommander.parse(args);
            typeNames = options.getInputs();
        }
        catch (final Throwable t) {
            System.err.println(ExceptionUtilities.getMessage(t));
            System.exit(-1);
            return;
        }

        configureLogging(options);

        final String jarFile = options.getJarFile();
        final boolean decompileJar = !StringUtilities.isNullOrWhitespace(jarFile);

        if (options.getPrintUsage() ||
            typeNames.isEmpty() && !decompileJar) {

            jCommander.usage();
            return;
        }

        final DecompilerSettings settings = new DecompilerSettings();

        settings.setFlattenSwitchBlocks(options.getFlattenSwitchBlocks());
        settings.setForceExplicitImports(options.getForceExplicitImports());
        settings.setForceExplicitTypeArguments(options.getForceExplicitTypeArguments());
        settings.setRetainRedundantCasts(options.getRetainRedundantCasts());
        settings.setShowSyntheticMembers(options.getShowSyntheticMembers());
        settings.setExcludeNestedTypes(options.getExcludeNestedTypes());
        settings.setOutputDirectory(options.getOutputDirectory());
        settings.setIncludeLineNumbersInBytecode(options.getIncludeLineNumbers());
        settings.setRetainPointlessSwitches(options.getRetainPointlessSwitches());
        settings.setUnicodeOutputEnabled(options.isUnicodeOutputEnabled());
        settings.setMergeVariables(options.getMergeVariables());
        settings.setShowDebugLineNumbers(options.getShowDebugLineNumbers());
        settings.setSimplifyMemberReferences(options.getSimplifyMemberReferences());
        settings.setDisableForEachTransforms(options.getDisableForEachTransforms());
        settings.setTypeLoader(new InputTypeLoader());

        if (options.isRawBytecode()) {
            settings.setLanguage(Languages.bytecode());
        }
        else if (options.isBytecodeAst()) {
            settings.setLanguage(
                options.isUnoptimized() ? Languages.bytecodeAstUnoptimized()
                                        : Languages.bytecodeAst()
            );
        }

        final DecompilationOptions decompilationOptions = new DecompilationOptions();

        decompilationOptions.setSettings(settings);
        decompilationOptions.setFullDecompilation(true);

        if (settings.getFormattingOptions() == null) {
            settings.setFormattingOptions(JavaFormattingOptions.createDefault());
        }

        if (decompileJar) {
            try {
                decompileJar(jarFile, options, decompilationOptions);
            }
            catch (final Throwable t) {
                System.err.println(ExceptionUtilities.getMessage(t));
                System.exit(-1);
            }
        }
        else {
            final MetadataSystem metadataSystem = new NoRetryMetadataSystem(settings.getTypeLoader());

            metadataSystem.setEagerMethodLoadingEnabled(options.isEagerMethodLoadingEnabled());

            for (final String typeName : typeNames) {
                try {
                    if (typeName.endsWith(".jar")) {
                        decompileJar(typeName, options, decompilationOptions);
                    }
                    else {
                        decompileType(metadataSystem, typeName, options, decompilationOptions, true);
                    }
                }
                catch (final Throwable t) {
                    t.printStackTrace();
                }
            }
        }
    }

    private static void configureLogging(final CommandLineOptions options) {
        final Logger globalLogger = Logger.getGlobal();
        final Logger rootLogger = Logger.getAnonymousLogger().getParent();

        for (final Handler handler : globalLogger.getHandlers()) {
            globalLogger.removeHandler(handler);
        }

        for (final Handler handler : rootLogger.getHandlers()) {
            rootLogger.removeHandler(handler);
        }

        final Level verboseLevel;

        switch (options.getVerboseLevel()) {
            case 0:
                verboseLevel = Level.SEVERE;
                break;
            case 1:
                verboseLevel = Level.FINE;
                break;
            case 2:
                verboseLevel = Level.FINER;
                break;
            case 3:
            default:
                verboseLevel = Level.FINEST;
                break;
        }

        globalLogger.setLevel(verboseLevel);
        rootLogger.setLevel(verboseLevel);

        final ConsoleHandler handler = new ConsoleHandler();

        handler.setLevel(verboseLevel);
        handler.setFormatter(new BriefLogFormatter());

        globalLogger.addHandler(handler);
        rootLogger.addHandler(handler);
    }

    private static void decompileJar(
        final String jarFilePath,
        final CommandLineOptions options,
        final DecompilationOptions decompilationOptions) throws IOException {

        final File jarFile = new File(jarFilePath);

        if (!jarFile.exists()) {
            throw new FileNotFoundException("File not found: " + jarFilePath);
        }

        final DecompilerSettings settings = decompilationOptions.getSettings();
        final JarFile jar = new JarFile(jarFile);
        final Enumeration<JarEntry> entries = jar.entries();

        final boolean oldShowSyntheticMembers = settings.getShowSyntheticMembers();
        final ITypeLoader oldTypeLoader = settings.getTypeLoader();

        settings.setShowSyntheticMembers(false);

        settings.setTypeLoader(
            new CompositeTypeLoader(
                new JarTypeLoader(jar),
                settings.getTypeLoader()
            )
        );

        try {
            MetadataSystem metadataSystem = new NoRetryMetadataSystem(settings.getTypeLoader());

            metadataSystem.setEagerMethodLoadingEnabled(options.isEagerMethodLoadingEnabled());

            int classesDecompiled = 0;

            while (entries.hasMoreElements()) {
                final JarEntry entry = entries.nextElement();
                final String name = entry.getName();

                if (!name.endsWith(".class")) {
                    continue;
                }

                final String internalName = StringUtilities.removeRight(name, ".class");

                try {
                    decompileType(metadataSystem, internalName, options, decompilationOptions, false);

                    if (++classesDecompiled % 100 == 0) {
                        metadataSystem = new NoRetryMetadataSystem(settings.getTypeLoader());
                    }
                }
                catch (final Throwable t) {
                    t.printStackTrace();
                }
            }
        }
        finally {
            settings.setShowSyntheticMembers(oldShowSyntheticMembers);
            settings.setTypeLoader(oldTypeLoader);
        }
    }

    private static void decompileType(
        final MetadataSystem metadataSystem,
        final String typeName,
        final CommandLineOptions commandLineOptions,
        final DecompilationOptions options,
        final boolean includeNested) throws IOException {

        final TypeReference type;
        final DecompilerSettings settings = options.getSettings();

        if (typeName.length() == 1) {
            //
            // Hack to get around classes whose descriptors clash with primitive types.
            //

            final MetadataParser parser = new MetadataParser(IMetadataResolver.EMPTY);
            final TypeReference reference = parser.parseTypeDescriptor(typeName);

            type = metadataSystem.resolve(reference);
        }
        else {
            type = metadataSystem.lookupType(typeName);
        }

        final TypeDefinition resolvedType;

        if (type == null || (resolvedType = type.resolve()) == null) {
            System.err.printf("!!! ERROR: Failed to load class %s.\n", typeName);
            return;
        }

        DeobfuscationUtilities.processType(resolvedType);

        if (!includeNested && (resolvedType.isNested() || resolvedType.isAnonymous() || resolvedType.isSynthetic())) {
            return;
        }

        final Writer writer = createWriter(resolvedType, settings);
        final boolean writeToFile = writer instanceof FileOutputWriter;
        final PlainTextOutput output;

        if (writeToFile) {
            output = new PlainTextOutput(writer);
        }
        else {
            output = new AnsiTextOutput(
                writer,
                commandLineOptions.getUseLightColorScheme() ? AnsiTextOutput.ColorScheme.LIGHT
                                                            : AnsiTextOutput.ColorScheme.DARK
            );
        }

        output.setUnicodeOutputEnabled(settings.isUnicodeOutputEnabled());

        if (settings.getLanguage() instanceof BytecodeLanguage) {
            output.setIndentToken("  ");
        }

        if (writeToFile) {
            System.out.printf("Decompiling %s...\n", typeName);
        }

        final TypeDecompilationResults results = settings.getLanguage().decompileType(resolvedType, output, options);

        writer.flush();
        writer.close();

        // If we're writing to a file and we were asked to include line numbers in any way,
        // then reformat the file to include that line number information.
        final List<LineNumberPosition> lineNumberPositions = results.getLineNumberPositions();

        if ((commandLineOptions.getIncludeLineNumbers() || commandLineOptions.getStretchLines()) && (writer instanceof FileOutputWriter)) {
            final EnumSet<LineNumberOption> lineNumberOptions = EnumSet.noneOf(LineNumberOption.class);

            if (commandLineOptions.getIncludeLineNumbers()) {
                lineNumberOptions.add(LineNumberOption.LEADING_COMMENTS);
            }

            if (commandLineOptions.getStretchLines()) {
                lineNumberOptions.add(LineNumberOption.STRETCHED);
            }

            final LineNumberFormatter lineFormatter = new LineNumberFormatter(
                ((FileOutputWriter) writer).getFile(),
                lineNumberPositions,
                lineNumberOptions
            );

            lineFormatter.reformatFile();
        }
    }

    private static Writer createWriter(final TypeDefinition type, final DecompilerSettings settings) throws IOException {
        final String outputDirectory = settings.getOutputDirectory();

        if (StringUtilities.isNullOrWhitespace(outputDirectory)) {
            return new OutputStreamWriter(
                System.out,
                settings.isUnicodeOutputEnabled() ? Charset.forName("UTF-8")
                                                  : Charset.defaultCharset()
            );
        }

        final String outputPath;
        final String fileName = type.getName() + settings.getLanguage().getFileExtension();
        final String packageName = type.getPackageName();

        if (StringUtilities.isNullOrWhitespace(packageName)) {
            outputPath = PathHelper.combine(outputDirectory, fileName);
        }
        else {
            outputPath = PathHelper.combine(
                outputDirectory,
                packageName.replace('.', PathHelper.DirectorySeparator),
                fileName
            );
        }

        final File outputFile = new File(outputPath);
        final File parentFile = outputFile.getParentFile();

        if (parentFile != null && !parentFile.exists() && !parentFile.mkdirs()) {
            throw new IllegalStateException(
                String.format(
                    "Could not create output directory for file \"%s\".",
                    outputPath
                )
            );
        }

        if (!outputFile.exists() && !outputFile.createNewFile()) {
            throw new IllegalStateException(
                String.format(
                    "Could not create output file \"%s\".",
                    outputPath
                )
            );
        }

        return new FileOutputWriter(outputFile, settings);
    }
}

final class FileOutputWriter extends OutputStreamWriter {
    private final File file;

    FileOutputWriter(final File file, final DecompilerSettings settings) throws IOException {
        super(
            new FileOutputStream(file),
            settings.isUnicodeOutputEnabled() ? Charset.forName("UTF-8")
                                              : Charset.defaultCharset()
        );
        this.file = file;
    }

    /**
     * Returns the file to which 'this' is writing.
     *
     * @return the file to which 'this' is writing
     */
    public File getFile() {
        return this.file;
    }
}

final class BriefLogFormatter extends Formatter {
    private static final DateFormat format = new SimpleDateFormat("h:mm:ss");
    private static final String lineSep = System.getProperty("line.separator");

    /**
     * A Custom format implementation that is designed for brevity.
     */
    public String format(@NotNull final LogRecord record) {
        String loggerName = record.getLoggerName();

        if (loggerName == null) {
            loggerName = "root";
        }

        return format.format(new Date(record.getMillis())) +
               " [" + record.getLevel() + "] " +
               loggerName + ": " + record.getMessage() + ' ' + lineSep;
    }
}

final class NoRetryMetadataSystem extends MetadataSystem {
    private final Set<String> _failedTypes = new HashSet<>();

    NoRetryMetadataSystem() {
    }

    NoRetryMetadataSystem(final String classPath) {
        super(classPath);
    }

    NoRetryMetadataSystem(final ITypeLoader typeLoader) {
        super(typeLoader);
    }

    @Override
    protected TypeDefinition resolveType(final String descriptor, final boolean mightBePrimitive) {
        if (_failedTypes.contains(descriptor)) {
            return null;
        }

        final TypeDefinition result = super.resolveType(descriptor, mightBePrimitive);

        if (result == null) {
            _failedTypes.add(descriptor);
        }

        return result;
    }
}