package io.gulp.intellij.plugin.decompile;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.intellij.notification.NotificationType.*;

import java.io.*;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.jar.JarOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import com.google.common.base.Strings;
import org.apache.commons.codec.Charsets;
import org.jetbrains.annotations.NotNull;

import com.google.common.base.Throwables;
import com.google.common.collect.Iterables;
import com.intellij.ide.util.PropertiesComponent;
import com.intellij.notification.Notification;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.application.Result;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.progress.ProcessCanceledException;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.*;
import com.intellij.openapi.roots.libraries.Library;
import com.intellij.openapi.roots.ui.configuration.PathUIUtils;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.JarFileSystem;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.compiled.ClassFileDecompilers;
import com.intellij.psi.impl.compiled.ClassFileDecompiler;
import com.intellij.util.CommonProcessors;

/**
 * Created by bduisenov on 12/11/15.
 */
public class DecompileAndAttachAction extends AnAction {

    private static final Logger logger = Logger.getInstance(DecompileAndAttachAction.class);

    private final String baseDirProjectSettingsKey = "io.gulp.intellij.baseDir";

    /**
     * show 'decompile and attach' option only for *.jar files
     * @param e
     */
    @Override
    public void update(AnActionEvent e) {
        Presentation presentation = e.getPresentation();
        presentation.setEnabled(false);
        presentation.setVisible(false);
        VirtualFile virtualFile = e.getData(CommonDataKeys.VIRTUAL_FILE);
        if (virtualFile != null && //
                "jar".equals(virtualFile.getExtension()) && //
                e.getProject() != null) {
            presentation.setEnabled(true);
            presentation.setVisible(true);
        }
    }

    @Override
    public void actionPerformed(AnActionEvent event) {
        Project project = event.getProject();

        if (project == null) {
            return;
        }

        final Optional<String> baseDirPath = getBaseDirPath(project);
        if (!baseDirPath.isPresent()) {
            return;
        }
        VirtualFile[] sourceVFs = event.getData(PlatformDataKeys.VIRTUAL_FILE_ARRAY);
        checkState(sourceVFs != null && sourceVFs.length > 0, "event#getData(VIRTUAL_FILE_ARRAY) returned empty array");
        new Task.Backgroundable(project, "Decompiling...", true) {

            @Override
            public void run(@NotNull ProgressIndicator indicator) {
                indicator.setFraction(0.1);

                Arrays.asList(sourceVFs).stream() //
                        .filter((vf) -> "jar".equals(vf.getExtension())) //
                        .forEach((sourceVF) -> process(project, baseDirPath.get(), sourceVF, indicator, 1D / sourceVFs.length));
                indicator.setFraction(1.0);
            }

            @Override
            public boolean shouldStartInBackground() {
                return true;
            }

        }.queue();
    }

    private Optional<String> getBaseDirPath(Project project) {
        String result = null;
        final String baseDirPath = PropertiesComponent.getInstance(project).getValue(baseDirProjectSettingsKey);
        if (Strings.isNullOrEmpty(baseDirPath)) {
            final FolderSelectionForm form = new FolderSelectionForm(project);
            if (form.showAndGet()) {
                result = form.getSelectedPath();
                PropertiesComponent.getInstance(project).setValue(baseDirProjectSettingsKey, result);
            }
        } else {
            result = baseDirPath;
        }
        return Optional.ofNullable(result);
    }

    private void process(Project project, String baseDirPath, VirtualFile sourceVF, ProgressIndicator indicator, double fractionStep) {
        indicator.setText("Decompiling '" + sourceVF.getName() + "'");
        JarFileSystem jarFileInstance = JarFileSystem.getInstance();
        VirtualFile jarRoot = jarFileInstance.getJarRootForLocalFile(sourceVF);
        if (jarRoot == null) {
            jarRoot = jarFileInstance.getJarRootForLocalFile(jarFileInstance.getLocalVirtualFileFor(sourceVF));
        }
        try {
            File tmpJarFile = FileUtil.createTempFile("decompiled", "tmp");
            Pair<String, Set<String>> result;
            try (JarOutputStream jarOutputStream = createJarOutputStream(tmpJarFile)) {
                result = processor(jarOutputStream, indicator).apply(jarRoot);
            }
            indicator.setFraction(indicator.getFraction() + (fractionStep * 70 / 100));
            indicator.setText("Attaching decompiled sources for '" + sourceVF.getName() + "'");
            result.second.forEach((failedFile) -> new Notification("DecompileAndAttach", "Decompilation problem",
                    "fernflower could not decompile class " + failedFile, WARNING).notify(project));
            File resultJar = copy(project, baseDirPath, sourceVF, tmpJarFile, result.first);
            attach(project, sourceVF, resultJar);
            indicator.setFraction(indicator.getFraction() + (fractionStep * 30 / 100));
            FileUtil.delete(tmpJarFile);
        } catch (Exception e) {
            if (!(e instanceof ProcessCanceledException)) {
                new Notification("DecompileAndAttach", "Jar lib couldn't be decompiled", e.getMessage(), ERROR).notify(project);
            }
            Throwables.propagate(e);
        }
    }

    private File copy(Project project, String baseDirPath, VirtualFile sourceVF, File tmpJarFile, String filename)
            throws IOException {
        String libraryName = filename.replace(".jar", "-sources.jar");
        String fullPath = baseDirPath + File.separator + libraryName;
        File result = new File(fullPath);
        if (result.exists()) {
            FileUtil.deleteWithRenaming(result);
            result = new File(fullPath);
            result.createNewFile();
        }
        FileUtil.copy(tmpJarFile, result);
        return result;
    }

    private void attach(final Project project, final VirtualFile sourceVF, File resultJar) {
        ApplicationManager.getApplication().invokeAndWait(() -> {
            VirtualFile resultJarVF = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(resultJar);
            checkNotNull(resultJarVF, "could not find Virtual File of %s", resultJar.getAbsolutePath());
            VirtualFile resultJarRoot = JarFileSystem.getInstance().getJarRootForLocalFile(resultJarVF);
            VirtualFile[] roots = PathUIUtils.scanAndSelectDetectedJavaSourceRoots(null,
                    new VirtualFile[] {resultJarRoot});
            new WriteCommandAction<Void>(project) {

                @Override
                protected void run(@NotNull Result<Void> result) throws Throwable {
                    final Module currentModule = ProjectRootManager.getInstance(project).getFileIndex()
                            .getModuleForFile(sourceVF, false);

                    checkNotNull(currentModule, "could not find current module");
                    Optional<Library> moduleLib = findModuleDependency(currentModule, sourceVF);
                    checkState(moduleLib.isPresent(), "could not find library in module dependencies");
                    Library.ModifiableModel model = moduleLib.get().getModifiableModel();
                    for (VirtualFile root : roots) {
                        model.addRoot(root, OrderRootType.SOURCES);
                    }
                    model.commit();

                    new Notification("DecompileAndAttach", "Jar Sources Added", "decompiled sources " + resultJar.getName()
                            + " where added successfully to dependency of a module '" + currentModule.getName() + "'",
                            INFORMATION).notify(project);
                }
            }.execute();
        } , ModalityState.NON_MODAL);
    }

    private Optional<Library> findModuleDependency(Module module, VirtualFile sourceVF) {
        final CommonProcessors.FindProcessor<OrderEntry> processor = new CommonProcessors.FindProcessor<OrderEntry>() {

            @Override
            protected boolean accept(OrderEntry orderEntry) {
                final String[] urls = orderEntry.getUrls(OrderRootType.CLASSES);
                final boolean contains = Arrays.asList(urls).contains("jar://" + sourceVF.getPath() + "!/");
                return contains && orderEntry instanceof LibraryOrderEntry;
            }
        };
        ModuleRootManager.getInstance(module).orderEntries().forEach(processor);
        Library result = null;
        if (processor.getFoundValue() != null) {
            result = ((LibraryOrderEntry) processor.getFoundValue()).getLibrary();
        }
        return Optional.ofNullable(result);
    }

    /**
     * recursively goes through jar archive and decompiles all found classes.
     * in case if decompilation fails on a class, the class name is put to {@code failed} Set
     * and then is returned with result.
     * @param jarOutputStream
     * @return {@code Pair<String, Set<String>>} containing the filename of a library and a set of
     * class names which failed to decompile
     */
    private Function<VirtualFile, Pair<String, Set<String>>> processor(JarOutputStream jarOutputStream, ProgressIndicator indicator) {
        return new Function<VirtualFile, Pair<String, Set<String>>>() {

            private String initialIndicatorText;

            private ClassFileDecompiler decompiler = new ClassFileDecompiler();

            private Set<String> failed = new HashSet<>();

            @Override
            public Pair<String, Set<String>> apply(VirtualFile head) {
                try {
                    VirtualFile[] children = head.getChildren();
                    checkState(children.length > 0, "jar file is empty");
                    initialIndicatorText = indicator.getText();
                    process("", head.getChildren()[0], Iterables.skip(Arrays.asList(children), 1), new HashSet<>());
                    final String libraryName = head.getName();
                    final Pair<String, Set<String>> result = Pair.create(libraryName, failed);
                    logger.debug("#apply({}): returned {}", head, result);
                    return result;
                } catch (IOException e) {
                    Throwables.propagate(e);
                }
                return null;
            }

            private void process(String relativePath, VirtualFile head, Iterable<VirtualFile> tail, Set<String> writtenPaths)
                    throws IOException {
                if (head == null) {
                    return;
                }
                VirtualFile[] children = head.getChildren();
                if (head.isDirectory() && children.length > 0) {
                    String path = relativePath + head.getName() + "/";
                    addDirectoryEntry(jarOutputStream, path, writtenPaths);
                    Iterable<VirtualFile> xs = Iterables.skip(Arrays.asList(children), 1);
                    indicator.setText(initialIndicatorText + " " + path);
                    process(path, children[0], xs, writtenPaths);
                } else {
                    if (!head.getName().contains("$") && "class".equals(head.getExtension())) {
                        decompileAndSave(relativePath + head.getNameWithoutExtension() + ".java", head, writtenPaths);
                    }
                }
                if (tail != null && !Iterables.isEmpty(tail)) {
                    process(relativePath, Iterables.getFirst(tail, null), Iterables.skip(tail, 1), writtenPaths);
                }
            }

            private void decompileAndSave(String relativeFilePath, VirtualFile file, Set<String> writternPaths)
                    throws IOException {
                try {
                    CharSequence decompiled = decompiler.decompile(file);
                    addFileEntry(jarOutputStream, relativeFilePath, writternPaths, decompiled);
                } catch (ClassFileDecompilers.Light.CannotDecompileException e) {
                    failed.add(file.getName());
                }
            }
        };
    }

    private static JarOutputStream createJarOutputStream(File jarFile) throws IOException {
        BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(jarFile));
        return new JarOutputStream(outputStream);
    }

    private static void addDirectoryEntry(ZipOutputStream output, String relativePath, Set<String> writtenPaths)
            throws IOException {
        if (!writtenPaths.add(relativePath))
            return;

        ZipEntry e = new ZipEntry(relativePath);
        e.setMethod(ZipEntry.STORED);
        e.setSize(0);
        e.setCrc(0);
        output.putNextEntry(e);
        output.closeEntry();
    }

    private static void addFileEntry(ZipOutputStream jarOS, String relativePath, Set<String> writtenPaths,
            CharSequence decompiled) throws IOException {
        if (!writtenPaths.add(relativePath))
            return;

        ByteArrayInputStream fileIS = new ByteArrayInputStream(decompiled.toString().getBytes(Charsets.toCharset("UTF-8")));
        long size = decompiled.length();
        ZipEntry e = new ZipEntry(relativePath);
        if (size == 0) {
            e.setMethod(ZipEntry.STORED);
            e.setSize(0);
            e.setCrc(0);
        }
        jarOS.putNextEntry(e);
        try {
            FileUtil.copy(fileIS, jarOS);
        } finally {
            fileIS.close();
        }
        jarOS.closeEntry();
    }

}