/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2018 hsz Jakub Chrzanowski <[email protected]>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

package mobi.hsz.idea.gitignore.ui;

import com.intellij.icons.AllIcons;
import com.intellij.ide.CommonActionsManager;
import com.intellij.ide.DefaultTreeExpander;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.editor.markup.HighlighterTargetArea;
import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.ui.OptionAction;
import com.intellij.openapi.util.IconLoader;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.PsiFile;
import com.intellij.ui.*;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.UIUtil;
import com.intellij.util.ui.tree.TreeUtil;
import mobi.hsz.idea.gitignore.IgnoreBundle;
import mobi.hsz.idea.gitignore.command.AppendFileCommandAction;
import mobi.hsz.idea.gitignore.command.CreateFileCommandAction;
import mobi.hsz.idea.gitignore.settings.IgnoreSettings;
import mobi.hsz.idea.gitignore.ui.template.TemplateTreeComparator;
import mobi.hsz.idea.gitignore.ui.template.TemplateTreeNode;
import mobi.hsz.idea.gitignore.ui.template.TemplateTreeRenderer;
import mobi.hsz.idea.gitignore.util.Constants;
import mobi.hsz.idea.gitignore.util.Resources;
import mobi.hsz.idea.gitignore.util.Utils;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;
import javax.swing.event.TreeSelectionListener;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.util.List;
import java.util.*;

import static mobi.hsz.idea.gitignore.util.Resources.Template.Container.STARRED;
import static mobi.hsz.idea.gitignore.util.Resources.Template.Container.USER;

/**
 * {@link GeneratorDialog} responsible for displaying list of all available templates and adding selected ones
 * to the specified file.
 *
 * @author Jakub Chrzanowski <[email protected]>
 * @since 0.2
 */
public class GeneratorDialog extends DialogWrapper {
    /** {@link FilterComponent} search history key. */
    @NonNls
    private static final String TEMPLATES_FILTER_HISTORY = "TEMPLATES_FILTER_HISTORY";

    /** Star icon for the favorites action. */
    private static final Icon STAR = AllIcons.Ide.Rating;

    /** Cache set to store checked templates for the current action. */
    private final Set<Resources.Template> checked = new HashSet<>();

    /** Set of the starred templates. */
    private final Set<String> starred = new HashSet<>();

    /** Current working project. */
    @NotNull
    private final Project project;

    /** Settings instance. */
    @NotNull
    private final IgnoreSettings settings;

    /** Current working file. */
    @Nullable
    private PsiFile file;

    /** Templates tree root node. */
    @NotNull
    private final TemplateTreeNode root;

    /** {@link CreateFileCommandAction} action instance to generate new file in the proper time. */
    @Nullable
    private CreateFileCommandAction action;

    /** Templates tree with checkbox feature. */
    private CheckboxTree tree;

    /** Tree expander responsible for expanding and collapsing tree structure. */
    private DefaultTreeExpander treeExpander;

    /** Dynamic templates filter. */
    private FilterComponent profileFilter;

    /** Preview editor with syntax highlight. */
    private Editor preview;

    /** {@link Document} related to the {@link Editor} feature. */
    private Document previewDocument;

    /** CheckboxTree selection listener. */
    private final TreeSelectionListener treeSelectionListener = e -> {
        final TreePath path = getCurrentPath();
        if (path != null) {
            updateDescriptionPanel(path);
        }
    };

    /**
     * Builds a new instance of {@link GeneratorDialog}.
     *
     * @param project current working project
     * @param file    current working file
     */
    public GeneratorDialog(@NotNull Project project, @Nullable PsiFile file) {
        super(project, false);
        this.project = project;
        this.file = file;
        this.root = new TemplateTreeNode();
        this.action = null;
        this.settings = IgnoreSettings.getInstance();

        setTitle(IgnoreBundle.message("dialog.generator.title"));
        setOKButtonText(IgnoreBundle.message("global.generate"));
        setCancelButtonText(IgnoreBundle.message("global.cancel"));
        init();
    }

    /**
     * Builds a new instance of {@link GeneratorDialog}.
     *
     * @param project current working project
     * @param action  {@link CreateFileCommandAction} action instance to generate new file in the proper time
     */
    public GeneratorDialog(@NotNull Project project, @Nullable CreateFileCommandAction action) {
        this(project, (PsiFile) null);
        this.action = action;
    }

    /**
     * Returns component which should be focused when the dialog appears on the screen.
     *
     * @return component to focus
     */
    @Nullable
    @Override
    public JComponent getPreferredFocusedComponent() {
        return profileFilter;
    }

    /**
     * Dispose the wrapped and releases all resources allocated be the wrapper to help
     * more efficient garbage collection. You should never invoke this method twice or
     * invoke any method of the wrapper after invocation of <code>dispose</code>.
     *
     * @throws IllegalStateException if the dialog is disposed not on the event dispatch thread
     */
    @Override
    protected void dispose() {
        tree.removeTreeSelectionListener(treeSelectionListener);
        EditorFactory.getInstance().releaseEditor(preview);
        super.dispose();
    }

    /**
     * Show the dialog.
     *
     * @throws IllegalStateException if the method is invoked not on the event dispatch thread
     * @see #showAndGet()
     */
    @Override
    public void show() {
        if (ApplicationManager.getApplication().isUnitTestMode()) {
            dispose();
            return;
        }
        super.show();
    }

    /**
     * This method is invoked by default implementation of "OK" action. It just closes dialog
     * with <code>OK_EXIT_CODE</code>. This is convenient place to override functionality of "OK" action.
     * Note that the method does nothing if "OK" action isn't enabled.
     */
    @Override
    protected void doOKAction() {
        if (isOKActionEnabled()) {
            performAppendAction(false, false);
        }
    }

    /**
     * Performs {@link AppendFileCommandAction} action.
     *
     * @param ignoreDuplicates ignores duplicated rules
     * @param ignoreComments   ignores comments and empty lines
     */
    private void performAppendAction(boolean ignoreDuplicates, boolean ignoreComments) {
        final StringBuilder content = new StringBuilder();
        final Iterator<Resources.Template> iterator = checked.iterator();
        while (iterator.hasNext()) {
            final Resources.Template template = iterator.next();
            if (template != null) {
                content.append(IgnoreBundle.message("file.templateSection", template.getName()));
                content.append(Constants.NEWLINE).append(template.getContent());

                if (iterator.hasNext()) {
                    content.append(Constants.NEWLINE);
                }
            }
        }
        try {
            if (file == null && action != null) {
                file = action.execute();
            }
            if (file != null && (content.length() > 0)) {
                new AppendFileCommandAction(project, file, content.toString(), ignoreDuplicates, ignoreComments)
                        .execute();
            }
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        super.doOKAction();
    }

    /** Creates default actions with appended {@link OptionOkAction} instance. */
    @Override
    protected void createDefaultActions() {
        super.createDefaultActions();
        myOKAction = new OptionOkAction();
    }

    /**
     * Factory method. It creates panel with dialog options. Options panel is located at the
     * center of the dialog's content pane. The implementation can return <code>null</code>
     * value. In this case there will be no options panel.
     *
     * @return center panel
     */
    @Nullable
    @Override
    protected JComponent createCenterPanel() {
        // general panel
        final JPanel centerPanel = new JPanel(new BorderLayout());
        centerPanel.setPreferredSize(new Dimension(800, 500));

        // splitter panel - contains tree panel and preview component
        final JBSplitter splitter = new JBSplitter(false, 0.4f);
        centerPanel.add(splitter, BorderLayout.CENTER);

        final JPanel treePanel = new JPanel(new BorderLayout());
        previewDocument = EditorFactory.getInstance().createDocument("");
        preview = Utils.createPreviewEditor(previewDocument, project, true);

        splitter.setFirstComponent(treePanel);
        splitter.setSecondComponent(preview.getComponent());

        /* Scroll panel for the templates tree. */
        JScrollPane treeScrollPanel = createTreeScrollPanel();
        treePanel.add(treeScrollPanel, BorderLayout.CENTER);

        final JPanel northPanel = new JPanel(new GridBagLayout());
        northPanel.setBorder(JBUI.Borders.empty(2, 0));
        northPanel.add(createTreeActionsToolbarPanel(treeScrollPanel).getComponent(),
                new GridBagConstraints(0, 0, 1, 1, 1, 1, GridBagConstraints.BASELINE_LEADING,
                        GridBagConstraints.HORIZONTAL, JBUI.emptyInsets(), 0, 0)
        );
        northPanel.add(profileFilter, new GridBagConstraints(1, 0, 1, 1, 1, 1, GridBagConstraints.BASELINE_TRAILING,
                GridBagConstraints.HORIZONTAL, JBUI.emptyInsets(), 0, 0));
        treePanel.add(northPanel, BorderLayout.NORTH);

        return centerPanel;
    }

    /**
     * Creates scroll panel with templates tree in it.
     *
     * @return scroll panel
     */
    private JScrollPane createTreeScrollPanel() {
        fillTreeData(null, true);

        final TemplateTreeRenderer renderer = new TemplateTreeRenderer() {
            protected String getFilter() {
                return profileFilter != null ? profileFilter.getFilter() : null;
            }
        };

        tree = new CheckboxTree(renderer, root) {
            public Dimension getPreferredScrollableViewportSize() {
                Dimension size = super.getPreferredScrollableViewportSize();
                size = new Dimension(size.width + 10, size.height);
                return size;
            }

            @Override
            protected void onNodeStateChanged(CheckedTreeNode node) {
                super.onNodeStateChanged(node);
                Resources.Template template = ((TemplateTreeNode) node).getTemplate();
                if (node.isChecked()) {
                    checked.add(template);
                } else {
                    checked.remove(template);
                }
            }
        };

        tree.setCellRenderer(renderer);
        tree.setRootVisible(false);
        tree.setShowsRootHandles(true);
        tree.addTreeSelectionListener(treeSelectionListener);
        TreeUtil.installActions(tree);

        final JScrollPane scrollPane = ScrollPaneFactory.createScrollPane(tree);
        scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED);
        TreeUtil.expandAll(tree);

        treeExpander = new DefaultTreeExpander(tree);
        profileFilter = new TemplatesFilterComponent();

        return scrollPane;
    }

    @Nullable
    private TreePath getCurrentPath() {
        if (tree.getSelectionPaths() != null && tree.getSelectionPaths().length == 1) {
            return tree.getSelectionPaths()[0];
        }
        return null;
    }

    /**
     * Creates tree toolbar panel with actions for working with templates tree.
     *
     * @param target templates tree
     * @return action toolbar
     */
    private ActionToolbar createTreeActionsToolbarPanel(@NotNull JComponent target) {
        final CommonActionsManager actionManager = CommonActionsManager.getInstance();

        DefaultActionGroup actions = new DefaultActionGroup();
        actions.add(actionManager.createExpandAllAction(treeExpander, tree));
        actions.add(actionManager.createCollapseAllAction(treeExpander, tree));
        actions.add(new AnAction(
                IgnoreBundle.message("dialog.generator.unselectAll"),
                null,
                AllIcons.Actions.Unselectall) {
            @Override
            public void update(@NotNull AnActionEvent e) {
                e.getPresentation().setEnabled(!checked.isEmpty());
            }

            @Override
            public void actionPerformed(@NotNull AnActionEvent e) {
                checked.clear();
                filterTree(profileFilter.getTextEditor().getText());
            }
        });
        actions.add(new AnAction(IgnoreBundle.message("dialog.generator.star"), null, STAR) {
            @Override
            public void update(@NotNull AnActionEvent e) {
                final TemplateTreeNode node = getCurrentNode();
                boolean disabled = node == null || USER.equals(node.getContainer()) || !node.isLeaf();
                boolean unstar = node != null && STARRED.equals(node.getContainer());

                final Icon icon = disabled ? IconLoader.getDisabledIcon(STAR) :
                        (unstar ? IconLoader.getTransparentIcon(STAR) : STAR);
                final String text = IgnoreBundle.message(unstar ? "dialog.generator.unstar" : "dialog.generator.star");

                final Presentation presentation = e.getPresentation();
                presentation.setEnabled(!disabled);
                presentation.setIcon(icon);
                presentation.setText(text);
            }

            @Override
            public void actionPerformed(@NotNull AnActionEvent e) {
                final TemplateTreeNode node = getCurrentNode();
                if (node == null) {
                    return;
                }

                final Resources.Template template = node.getTemplate();
                if (template != null) {
                    boolean isStarred = !template.isStarred();
                    template.setStarred(isStarred);
                    refreshTree();

                    if (isStarred) {
                        starred.add(template.getName());
                    } else {
                        starred.remove(template.getName());
                    }

                    settings.setStarredTemplates(new ArrayList<>(starred));
                }
            }

            /**
             * Returns current {@link TemplateTreeNode} node if available.
             *
             * @return current node
             */
            @Nullable
            private TemplateTreeNode getCurrentNode() {
                final TreePath path = getCurrentPath();
                return path == null ? null : (TemplateTreeNode) path.getLastPathComponent();
            }
        });

        final ActionToolbar actionToolbar = ActionManager.getInstance()
                .createActionToolbar(ActionPlaces.UNKNOWN, actions, true);
        actionToolbar.setTargetComponent(target);
        return actionToolbar;
    }

    /**
     * Updates editor's content depending on the selected {@link TreePath}.
     *
     * @param path selected tree path
     */
    private void updateDescriptionPanel(@NotNull TreePath path) {
        final TemplateTreeNode node = (TemplateTreeNode) path.getLastPathComponent();
        final Resources.Template template = node.getTemplate();

        ApplicationManager.getApplication().runWriteAction(
                () -> CommandProcessor.getInstance().runUndoTransparentAction(() -> {
                    String content = template != null ?
                            StringUtil.notNullize(template.getContent()).replace('\r', '\0') : "";
                    previewDocument.replaceString(0, previewDocument.getTextLength(), content);

                    List<Pair<Integer, Integer>> pairs =
                            getFilterRanges(profileFilter.getTextEditor().getText(), content);
                    highlightWords(pairs);
                })
        );
    }

    /**
     * Fills templates tree with templates fetched with {@link Resources#getGitignoreTemplates()}.
     *
     * @param filter       templates filter
     * @param forceInclude force include
     */
    private void fillTreeData(@Nullable String filter, boolean forceInclude) {
        root.removeAllChildren();
        root.setChecked(false);

        for (Resources.Template.Container container : Resources.Template.Container.values()) {
            TemplateTreeNode node = new TemplateTreeNode(container);
            node.setChecked(false);
            root.add(node);
        }

        List<Resources.Template> templatesList = Resources.getGitignoreTemplates();
        for (Resources.Template template : templatesList) {
            if (filter != null && filter.length() > 0 && !isTemplateAccepted(template, filter)) {
                continue;
            }

            final TemplateTreeNode node = new TemplateTreeNode(template);
            node.setChecked(checked.contains(template));
            getGroupNode(root, template.getContainer()).add(node);
        }

        if (filter != null && forceInclude && root.getChildCount() == 0) {
            fillTreeData(filter, false);
        }

        TreeUtil.sort(root, new TemplateTreeComparator());
    }

    /**
     * Creates or gets existing group node for specified element.
     *
     * @param root      tree root node
     * @param container container type to search
     * @return group node
     */
    private static TemplateTreeNode getGroupNode(@NotNull TemplateTreeNode root,
                                                 @NotNull Resources.Template.Container container) {
        final int childCount = root.getChildCount();

        for (int i = 0; i < childCount; i++) {
            TemplateTreeNode child = (TemplateTreeNode) root.getChildAt(i);
            if (container.equals(child.getContainer())) {
                return child;
            }
        }

        TemplateTreeNode child = new TemplateTreeNode(container);
        root.add(child);
        return child;
    }

    /**
     * Finds for the filter's words in the given content and returns their positions.
     *
     * @param filter  templates filter
     * @param content templates content
     * @return text ranges
     */
    private List<Pair<Integer, Integer>> getFilterRanges(@NotNull String filter, @NotNull String content) {
        List<Pair<Integer, Integer>> pairs = new ArrayList<>();
        content = content.toLowerCase();

        for (String word : Utils.getWords(filter)) {
            for (int index = content.indexOf(word); index >= 0; index = content.indexOf(word, index + 1)) {
                pairs.add(Pair.create(index, index + word.length()));
            }
        }

        return pairs;
    }

    /**
     * Checks if given template is accepted by passed filter.
     *
     * @param template to check
     * @param filter   templates filter
     * @return template is accepted
     */
    private boolean isTemplateAccepted(@NotNull Resources.Template template, @NotNull String filter) {
        filter = filter.toLowerCase();

        if (StringUtil.containsIgnoreCase(template.getName(), filter)) {
            return true;
        }

        boolean nameAccepted = true;
        for (String word : Utils.getWords(filter)) {
            if (!StringUtil.containsIgnoreCase(template.getName(), word)) {
                nameAccepted = false;
                break;
            }
        }

        List<Pair<Integer, Integer>> ranges = getFilterRanges(filter, StringUtil.notNullize(template.getContent()));
        return nameAccepted || ranges.size() > 0;
    }

    /**
     * Filters templates tree.
     *
     * @param filter text
     */
    private void filterTree(@Nullable String filter) {
        if (tree != null) {
            fillTreeData(filter, true);
            reloadModel();
            TreeUtil.expandAll(tree);
            if (tree.getSelectionPath() == null) {
                TreeUtil.selectFirstNode(tree);
            }
        }
    }

    /** Refreshes current tree. */
    private void refreshTree() {
        filterTree(profileFilter.getTextEditor().getText());
    }

    /**
     * Highlights given text ranges in {@link #preview} content.
     *
     * @param pairs text ranges
     */
    private void highlightWords(@NotNull List<Pair<Integer, Integer>> pairs) {
        final TextAttributes attr = new TextAttributes();
        attr.setBackgroundColor(UIUtil.getTreeSelectionBackground(true));
        attr.setForegroundColor(UIUtil.getTreeSelectionForeground(true));

        for (Pair<Integer, Integer> pair : pairs) {
            preview.getMarkupModel().addRangeHighlighter(pair.first, pair.second, 0, attr,
                    HighlighterTargetArea.EXACT_RANGE);
        }
    }

    /** Reloads tree model. */
    private void reloadModel() {
        ((DefaultTreeModel) tree.getModel()).reload();
    }

    /**
     * Returns current file.
     *
     * @return file
     */
    @Nullable
    public PsiFile getFile() {
        return file;
    }

    /** Custom templates {@link FilterComponent}. */
    private class TemplatesFilterComponent extends FilterComponent {
        /** Builds a new instance of {@link TemplatesFilterComponent}. */
        public TemplatesFilterComponent() {
            super(TEMPLATES_FILTER_HISTORY, 10);
        }

        /** Filters tree using current filter's value. */
        @Override
        public void filter() {
            filterTree(getFilter());
        }
    }

    /** {@link OkAction} instance with additional `Generate without duplicates` action. */
    private class OptionOkAction extends OkAction implements OptionAction {
        @NotNull
        @Override
        public Action[] getOptions() {
            return new Action[]{
                    new DialogWrapperAction(IgnoreBundle.message("global.generate.without.duplicates")) {
                        @Override
                        protected void doAction(ActionEvent e) {
                            performAppendAction(true, false);
                        }
                    },
                    new DialogWrapperAction(IgnoreBundle.message("global.generate.without.comments")) {
                        @Override
                        protected void doAction(ActionEvent e) {
                            performAppendAction(false, true);
                        }
                    }
            };
        }
    }
}