// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.actions.search;

import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
import static org.openstreetmap.josm.tools.I18n.tr;
import static org.openstreetmap.josm.tools.I18n.trc;

import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagLayout;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
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.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.swing.ButtonGroup;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.text.BadLocationException;
import javax.swing.text.JTextComponent;

import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.actions.ActionParameter;
import org.openstreetmap.josm.actions.ActionParameter.SearchSettingsActionParameter;
import org.openstreetmap.josm.actions.JosmAction;
import org.openstreetmap.josm.actions.ParameterizedAction;
import org.openstreetmap.josm.actions.search.SearchCompiler.ParseError;
import org.openstreetmap.josm.data.osm.DataSet;
import org.openstreetmap.josm.data.osm.Filter;
import org.openstreetmap.josm.data.osm.OsmPrimitive;
import org.openstreetmap.josm.gui.ExtendedDialog;
import org.openstreetmap.josm.gui.preferences.ToolbarPreferences;
import org.openstreetmap.josm.gui.preferences.ToolbarPreferences.ActionParser;
import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
import org.openstreetmap.josm.tools.GBC;
import org.openstreetmap.josm.tools.Predicate;
import org.openstreetmap.josm.tools.Property;
import org.openstreetmap.josm.tools.Shortcut;
import org.openstreetmap.josm.tools.Utils;


public class SearchAction extends JosmAction implements ParameterizedAction {

    public static final int DEFAULT_SEARCH_HISTORY_SIZE = 15;
    /** Maximum number of characters before the search expression is shortened for display purposes. */
    public static final int MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY = 100;

    private static final String SEARCH_EXPRESSION = "searchExpression";

    public static enum SearchMode {
        replace('R'), add('A'), remove('D'), in_selection('S');

        private final char code;

        SearchMode(char code) {
            this.code = code;
        }

        public char getCode() {
            return code;
        }

        public static SearchMode fromCode(char code) {
            for (SearchMode mode: values()) {
                if (mode.getCode() == code)
                    return mode;
            }
            return null;
        }
    }

    private static final LinkedList<SearchSetting> searchHistory = new LinkedList<>();
    static {
        for (String s: Main.pref.getCollection("search.history", Collections.<String>emptyList())) {
            SearchSetting ss = SearchSetting.readFromString(s);
            if (ss != null) {
                searchHistory.add(ss);
            }
        }
    }

    public static Collection<SearchSetting> getSearchHistory() {
        return searchHistory;
    }

    public static void saveToHistory(SearchSetting s) {
        if (searchHistory.isEmpty() || !s.equals(searchHistory.getFirst())) {
            searchHistory.addFirst(new SearchSetting(s));
        } else if (searchHistory.contains(s)) {
            // move existing entry to front, fixes #8032 - search history loses entries when re-using queries
            searchHistory.remove(s);
            searchHistory.addFirst(new SearchSetting(s));
        }
        int maxsize = Main.pref.getInteger("search.history-size", DEFAULT_SEARCH_HISTORY_SIZE);
        while (searchHistory.size() > maxsize) {
            searchHistory.removeLast();
        }
        Set<String> savedHistory = new LinkedHashSet<>(searchHistory.size());
        for (SearchSetting item: searchHistory) {
            savedHistory.add(item.writeToString());
        }
        Main.pref.putCollection("search.history", savedHistory);
    }

    public static List<String> getSearchExpressionHistory() {
        List<String> ret = new ArrayList<>(getSearchHistory().size());
        for (SearchSetting ss: getSearchHistory()) {
            ret.add(ss.text);
        }
        return ret;
    }

    private static volatile SearchSetting lastSearch = null;

    /**
     * Constructs a new {@code SearchAction}.
     */
    public SearchAction() {
        super(tr("Search..."), "dialogs/search", tr("Search for objects."),
                Shortcut.registerShortcut("system:find", tr("Search..."), KeyEvent.VK_F, Shortcut.CTRL), true);
        putValue("help", ht("/Action/Search"));
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        if (!isEnabled())
            return;
        search();
    }

    @Override
    public void actionPerformed(ActionEvent e, Map<String, Object> parameters) {
        if (parameters.get(SEARCH_EXPRESSION) == null) {
            actionPerformed(e);
        } else {
            searchWithoutHistory((SearchSetting) parameters.get(SEARCH_EXPRESSION));
        }
    }

    private static class DescriptionTextBuilder {

        private final StringBuilder s = new StringBuilder(4096);

        public StringBuilder append(String string) {
            return s.append(string);
        }

        StringBuilder appendItem(String item) {
            return append("<li>").append(item).append("</li>\n");
        }

        StringBuilder appendItemHeader(String itemHeader) {
            return append("<li class=\"header\">").append(itemHeader).append("</li>\n");
        }

        @Override
        public String toString() {
            return s.toString();
        }
    }

    private static class SearchKeywordRow extends JPanel {

        private final HistoryComboBox hcb;

        public SearchKeywordRow(HistoryComboBox hcb) {
            super(new FlowLayout(FlowLayout.LEFT));
            this.hcb = hcb;
        }

        public SearchKeywordRow addTitle(String title) {
            add(new JLabel(tr("{0}: ", title)));
            return this;
        }

        public SearchKeywordRow addKeyword(String displayText, final String insertText, String description, String... examples) {
            JLabel label = new JLabel("<html>"
                    + "<style>td{border:1px solid gray; font-weight:normal;}</style>"
                    + "<table><tr><td>" + displayText + "</td></tr></table></html>");
            add(label);
            if (description != null || examples.length > 0) {
                label.setToolTipText("<html>"
                        + description
                        + (examples.length > 0 ? Utils.joinAsHtmlUnorderedList(Arrays.asList(examples)) : "")
                        + "</html>");
            }
            if (insertText != null) {
                label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
                label.addMouseListener(new MouseAdapter() {

                    @Override
                    public void mouseClicked(MouseEvent e) {
                        try {
                            JTextComponent tf = (JTextComponent) hcb.getEditor().getEditorComponent();
                            tf.getDocument().insertString(tf.getCaretPosition(), " " + insertText, null);
                        } catch (BadLocationException ex) {
                            throw new RuntimeException(ex.getMessage(), ex);
                        }
                    }
                });
            }
            return this;
        }
    }

    public static SearchSetting showSearchDialog(SearchSetting initialValues) {
        if (initialValues == null) {
            initialValues = new SearchSetting();
        }
        // -- prepare the combo box with the search expressions
        //
        JLabel label = new JLabel(initialValues instanceof Filter ? tr("Filter string:") : tr("Search string:"));
        final HistoryComboBox hcbSearchString = new HistoryComboBox();
        hcbSearchString.setText(initialValues.text);
        hcbSearchString.setToolTipText(tr("Enter the search expression"));
        // we have to reverse the history, because ComboBoxHistory will reverse it again in addElement()
        //
        List<String> searchExpressionHistory = getSearchExpressionHistory();
        Collections.reverse(searchExpressionHistory);
        hcbSearchString.setPossibleItems(searchExpressionHistory);
        hcbSearchString.setPreferredSize(new Dimension(40, hcbSearchString.getPreferredSize().height));
        label.setLabelFor(hcbSearchString);

        JRadioButton replace = new JRadioButton(tr("replace selection"), initialValues.mode == SearchMode.replace);
        JRadioButton add = new JRadioButton(tr("add to selection"), initialValues.mode == SearchMode.add);
        JRadioButton remove = new JRadioButton(tr("remove from selection"), initialValues.mode == SearchMode.remove);
        JRadioButton in_selection = new JRadioButton(tr("find in selection"), initialValues.mode == SearchMode.in_selection);
        ButtonGroup bg = new ButtonGroup();
        bg.add(replace);
        bg.add(add);
        bg.add(remove);
        bg.add(in_selection);

        final JCheckBox caseSensitive = new JCheckBox(tr("case sensitive"), initialValues.caseSensitive);
        JCheckBox allElements = new JCheckBox(tr("all objects"), initialValues.allElements);
        allElements.setToolTipText(tr("Also include incomplete and deleted objects in search."));
        final JCheckBox regexSearch   = new JCheckBox(tr("regular expression"), initialValues.regexSearch);
        final JCheckBox addOnToolbar  = new JCheckBox(tr("add toolbar button"), false);

        JPanel top = new JPanel(new GridBagLayout());
        top.add(label, GBC.std().insets(0, 0, 5, 0));
        top.add(hcbSearchString, GBC.eol().fill(GBC.HORIZONTAL));
        JPanel left = new JPanel(new GridBagLayout());
        left.add(replace, GBC.eol());
        left.add(add, GBC.eol());
        left.add(remove, GBC.eol());
        left.add(in_selection, GBC.eop());
        left.add(caseSensitive, GBC.eol());
        if (Main.pref.getBoolean("expert", false)) {
            left.add(allElements, GBC.eol());
            left.add(regexSearch, GBC.eol());
            left.add(addOnToolbar, GBC.eol());
        }

        final JPanel right;
        right = new JPanel(new GridBagLayout());
        buildHints(right, hcbSearchString);

        final JPanel p = new JPanel(new GridBagLayout());
        p.add(top, GBC.eol().fill(GBC.HORIZONTAL).insets(5, 5, 5, 0));
        p.add(left, GBC.std().anchor(GBC.NORTH).insets(5, 10, 10, 0));
        p.add(right, GBC.eol());
        ExtendedDialog dialog = new ExtendedDialog(
                Main.parent,
                initialValues instanceof Filter ? tr("Filter") : tr("Search"),
                        new String[] {
                    initialValues instanceof Filter ? tr("Submit filter") : tr("Start Search"),
                            tr("Cancel")}
        ) {
            @Override
            protected void buttonAction(int buttonIndex, ActionEvent evt) {
                if (buttonIndex == 0) {
                    try {
                        SearchCompiler.compile(hcbSearchString.getText(), caseSensitive.isSelected(), regexSearch.isSelected());
                        super.buttonAction(buttonIndex, evt);
                    } catch (ParseError e) {
                        JOptionPane.showMessageDialog(
                                Main.parent,
                                tr("Search expression is not valid: \n\n {0}", e.getMessage()),
                                tr("Invalid search expression"),
                                JOptionPane.ERROR_MESSAGE);
                    }
                } else {
                    super.buttonAction(buttonIndex, evt);
                }
            }
        };
        dialog.setButtonIcons(new String[] {"dialogs/search", "cancel"});
        dialog.configureContextsensitiveHelp("/Action/Search", true /* show help button */);
        dialog.setContent(p);
        dialog.showDialog();
        int result = dialog.getValue();

        if (result != 1) return null;

        // User pressed OK - let's perform the search
        SearchMode mode = replace.isSelected() ? SearchAction.SearchMode.replace
                : (add.isSelected() ? SearchAction.SearchMode.add
                        : (remove.isSelected() ? SearchAction.SearchMode.remove : SearchAction.SearchMode.in_selection));
        initialValues.text = hcbSearchString.getText();
        initialValues.mode = mode;
        initialValues.caseSensitive = caseSensitive.isSelected();
        initialValues.allElements = allElements.isSelected();
        initialValues.regexSearch = regexSearch.isSelected();

        if (addOnToolbar.isSelected()) {
            ToolbarPreferences.ActionDefinition aDef =
                    new ToolbarPreferences.ActionDefinition(Main.main.menu.search);
            aDef.getParameters().put(SEARCH_EXPRESSION, initialValues);
            // Display search expression as tooltip instead of generic one
            aDef.setName(Utils.shortenString(initialValues.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY));
            // parametrized action definition is now composed
            ActionParser actionParser = new ToolbarPreferences.ActionParser(null);
            String res = actionParser.saveAction(aDef);

            // add custom search button to toolbar preferences
            Main.toolbar.addCustomButton(res, -1, false);
        }
        return initialValues;
    }

    private static void buildHints(JPanel right, HistoryComboBox hcbSearchString) {
        right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("basic examples"))
                .addKeyword(tr("Baker Street"), null, tr("''Baker'' and ''Street'' in any key"))
                .addKeyword(tr("\"Baker Street\""), "\"\"", tr("''Baker Street'' in any key"))
                , GBC.eol());
        right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("basics"))
                .addKeyword("<i>key</i>:<i>valuefragment</i>", null,
                        tr("''valuefragment'' anywhere in ''key''"), "name:str matches name=Bakerstreet")
                .addKeyword("-<i>key</i>:<i>valuefragment</i>", null, tr("''valuefragment'' nowhere in ''key''"))
                .addKeyword("<i>key</i>=<i>value</i>", null, tr("''key'' with exactly ''value''"))
                .addKeyword("<i>key</i>=*", null, tr("''key'' with any value"))
                .addKeyword("*=<i>value</i>", null, tr("''value'' in any key"))
                .addKeyword("<i>key</i>=", null, tr("matches if ''key'' exists"))
                .addKeyword("<i>key</i>><i>value</i>", null, tr("matches if ''key'' is greater than ''value'' (analogously, less than)"))
                .addKeyword("\"key\"=\"value\"", "\"\"=\"\"",
                        tr("to quote operators.<br>Within quoted strings the <b>\"</b> and <b>\\</b> characters need to be escaped " +
                           "by a preceding <b>\\</b> (e.g. <b>\\\"</b> and <b>\\\\</b>)."),
                        "\"addr:street\"")
                , GBC.eol());
        right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("combinators"))
                .addKeyword("<i>expr</i> <i>expr</i>", null, tr("logical and (both expressions have to be satisfied)"))
                .addKeyword("<i>expr</i> | <i>expr</i>", "| ", tr("logical or (at least one expression has to be satisfied)"))
                .addKeyword("<i>expr</i> OR <i>expr</i>", "OR ", tr("logical or (at least one expression has to be satisfied)"))
                .addKeyword("-<i>expr</i>", null, tr("logical not"))
                .addKeyword("(<i>expr</i>)", "()", tr("use parenthesis to group expressions"))
                , GBC.eol());

        if (Main.pref.getBoolean("expert", false)) {
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("objects"))
                .addKeyword("type:node", "type:node ", tr("all ways"))
                .addKeyword("type:way", "type:way ", tr("all ways"))
                .addKeyword("type:relation", "type:relation ", tr("all relations"))
                .addKeyword("closed", "closed ", tr("all closed ways"))
                .addKeyword("untagged", "untagged ", tr("object without useful tags"))
                , GBC.eol());
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("metadata"))
                .addKeyword("user:", "user:", tr("objects changed by user", "user:anonymous"))
                .addKeyword("id:", "id:", tr("objects with given ID"), "id:0 (new objects)")
                .addKeyword("version:", "version:", tr("objects with given version"), "version:0 (objects without an assigned version)")
                .addKeyword("changeset:", "changeset:", tr("objects with given changeset ID"),
                        "changeset:0 (objects without an assigned changeset)")
                .addKeyword("timestamp:", "timestamp:", tr("objects with last modification timestamp within range"), "timestamp:2012/",
                        "timestamp:2008/2011-02-04T12")
                , GBC.eol());
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("properties"))
                .addKeyword("nodes:<i>20-</i>", "nodes:", tr("ways with at least 20 nodes, or relations containing at least 20 nodes"))
                .addKeyword("ways:<i>3-</i>", "ways:", tr("nodes with at least 3 referring ways, or relations containing at least 3 ways"))
                .addKeyword("tags:<i>5-10</i>", "tags:", tr("objects having 5 to 10 tags"))
                .addKeyword("role:", "role:", tr("objects with given role in a relation"))
                .addKeyword("areasize:<i>-100</i>", "areasize:", tr("closed ways with an area of 100 m\u00b2"))
                .addKeyword("waylength:<i>200-</i>", "waylength:", tr("ways with a length of 200 m or more"))
                , GBC.eol());
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("state"))
                .addKeyword("modified", "modified ", tr("all modified objects"))
                .addKeyword("new", "new ", tr("all new objects"))
                .addKeyword("selected", "selected ", tr("all selected objects"))
                .addKeyword("incomplete", "incomplete ", tr("all incomplete objects"))
                , GBC.eol());
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("related objects"))
                .addKeyword("child <i>expr</i>", "child ", tr("all children of objects matching the expression"), "child building")
                .addKeyword("parent <i>expr</i>", "parent ", tr("all parents of objects matching the expression"), "parent bus_stop")
                .addKeyword("nth:<i>7</i>", "nth: ", tr("n-th member of relation and/or n-th node of way"), "nth:5 (child type:relation)", "nth:-1")
                .addKeyword("nth%:<i>7</i>", "nth%: ", tr("every n-th member of relation and/or every n-th node of way"), "nth%:100 (child waterway)")
                , GBC.eol());
            right.add(new SearchKeywordRow(hcbSearchString)
                .addTitle(tr("view"))
                .addKeyword("inview", "inview ", tr("objects in current view"))
                .addKeyword("allinview", "allinview ", tr("objects (and all its way nodes / relation members) in current view"))
                .addKeyword("indownloadedarea", "indownloadedarea ", tr("objects in downloaded area"))
                .addKeyword("allindownloadedarea", "allindownloadedarea ",
                        tr("objects (and all its way nodes / relation members) in downloaded area"))
                , GBC.eol());
        }
    }

    /**
     * Launches the dialog for specifying search criteria and runs a search
     */
    public static void search() {
        SearchSetting se = showSearchDialog(lastSearch);
        if (se != null) {
            searchWithHistory(se);
        }
    }

    /**
     * Adds the search specified by the settings in <code>s</code> to the
     * search history and performs the search.
     *
     * @param s search settings
     */
    public static void searchWithHistory(SearchSetting s) {
        saveToHistory(s);
        lastSearch = new SearchSetting(s);
        search(s);
    }

    /**
     * Performs the search specified by the settings in <code>s</code> without saving it to search history.
     *
     * @param s search settings
     */
    public static void searchWithoutHistory(SearchSetting s) {
        lastSearch = new SearchSetting(s);
        search(s);
    }

    public static int getSelection(SearchSetting s, Collection<OsmPrimitive> sel, Predicate<OsmPrimitive> p) {
        int foundMatches = 0;
        try {
            String searchText = s.text;
            SearchCompiler.Match matcher = SearchCompiler.compile(searchText, s.caseSensitive, s.regexSearch);

            if (s.mode == SearchMode.replace) {
                sel.clear();
            } else if (s.mode == SearchMode.in_selection) {
                foundMatches = sel.size();
            }

            Collection<OsmPrimitive> all;
            if (s.allElements) {
                all = Main.main.getCurrentDataSet().allPrimitives();
            } else {
                all = Main.main.getCurrentDataSet().allNonDeletedCompletePrimitives();
            }

            for (OsmPrimitive osm : all) {
                if (s.mode == SearchMode.replace) {
                    if (matcher.match(osm)) {
                        sel.add(osm);
                        ++foundMatches;
                    }
                } else if (s.mode == SearchMode.add && !p.evaluate(osm) && matcher.match(osm)) {
                    sel.add(osm);
                    ++foundMatches;
                } else if (s.mode == SearchMode.remove && p.evaluate(osm) && matcher.match(osm)) {
                    sel.remove(osm);
                    ++foundMatches;
                } else if (s.mode == SearchMode.in_selection && p.evaluate(osm) && !matcher.match(osm)) {
                    sel.remove(osm);
                    --foundMatches;
                }
            }
        } catch (SearchCompiler.ParseError e) {
            JOptionPane.showMessageDialog(
                    Main.parent,
                    e.getMessage(),
                    tr("Error"),
                    JOptionPane.ERROR_MESSAGE

            );
        }
        return foundMatches;
    }

    /**
     * Version of getSelection that is customized for filter, but should also work in other context.
     *
     * @param s the search settings
     * @param all the collection of all the primitives that should be considered
     * @param p the property that should be set/unset if something is found
     */
    public static void getSelection(SearchSetting s, Collection<OsmPrimitive> all, Property<OsmPrimitive, Boolean> p) {
        try {
            String searchText = s.text;
            if (s instanceof Filter && ((Filter) s).inverted) {
                searchText = String.format("-(%s)", searchText);
            }
            SearchCompiler.Match matcher = SearchCompiler.compile(searchText, s.caseSensitive, s.regexSearch);

            for (OsmPrimitive osm : all) {
                if (s.mode == SearchMode.replace) {
                    if (matcher.match(osm)) {
                        p.set(osm, Boolean.TRUE);
                    } else {
                        p.set(osm, Boolean.FALSE);
                    }
                } else if (s.mode == SearchMode.add && !p.get(osm) && matcher.match(osm)) {
                    p.set(osm, Boolean.TRUE);
                } else if (s.mode == SearchMode.remove && p.get(osm) && matcher.match(osm)) {
                    p.set(osm, Boolean.FALSE);
                } else if (s.mode == SearchMode.in_selection && p.get(osm) && !matcher.match(osm)) {
                    p.set(osm, Boolean.FALSE);
                }
            }
        } catch (SearchCompiler.ParseError e) {
            JOptionPane.showMessageDialog(
                    Main.parent,
                    e.getMessage(),
                    tr("Error"),
                    JOptionPane.ERROR_MESSAGE
            );
        }
    }

    public static void search(String search, SearchMode mode) {
        search(new SearchSetting(search, mode, false, false, false));
    }

    public static void search(SearchSetting s) {

        final DataSet ds = Main.main.getCurrentDataSet();
        Collection<OsmPrimitive> sel = new HashSet<>(ds.getAllSelected());
        int foundMatches = getSelection(s, sel, new Predicate<OsmPrimitive>() {
            @Override
            public boolean evaluate(OsmPrimitive o) {
                return ds.isSelected(o);
            }
        });
        ds.setSelected(sel);
        if (foundMatches == 0) {
            String msg = null;
            final String text = Utils.shortenString(s.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY);
            if (s.mode == SearchMode.replace) {
                msg = tr("No match found for ''{0}''", text);
            } else if (s.mode == SearchMode.add) {
                msg = tr("Nothing added to selection by searching for ''{0}''", text);
            } else if (s.mode == SearchMode.remove) {
                msg = tr("Nothing removed from selection by searching for ''{0}''", text);
            } else if (s.mode == SearchMode.in_selection) {
                msg = tr("Nothing found in selection by searching for ''{0}''", text);
            }
            Main.map.statusLine.setHelpText(msg);
            JOptionPane.showMessageDialog(
                    Main.parent,
                    msg,
                    tr("Warning"),
                    JOptionPane.WARNING_MESSAGE
            );
        } else {
            Main.map.statusLine.setHelpText(tr("Found {0} matches", foundMatches));
        }
    }

    public static class SearchSetting {
        public String text;
        public SearchMode mode;
        public boolean caseSensitive;
        public boolean regexSearch;
        public boolean allElements;

        /**
         * Constructs a new {@code SearchSetting}.
         */
        public SearchSetting() {
            this("", SearchMode.replace, false /* case insensitive */,
                    false /* no regexp */, false /* only useful primitives */);
        }

        public SearchSetting(String text, SearchMode mode, boolean caseSensitive,
                boolean regexSearch, boolean allElements) {
            this.caseSensitive = caseSensitive;
            this.regexSearch = regexSearch;
            this.allElements = allElements;
            this.mode = mode;
            this.text = text;
        }

        public SearchSetting(SearchSetting original) {
            this(original.text, original.mode, original.caseSensitive,
                    original.regexSearch, original.allElements);
        }

        @Override
        public String toString() {
            String cs = caseSensitive ?
                    /*case sensitive*/  trc("search", "CS") :
                        /*case insensitive*/  trc("search", "CI");
                    String rx = regexSearch ? ", " +
                            /*regex search*/ trc("search", "RX") : "";
                    String all = allElements ? ", " +
                            /*all elements*/ trc("search", "A") : "";
                    return "\"" + text + "\" (" + cs + rx + all + ", " + mode + ")";
        }

        @Override
        public boolean equals(Object other) {
            if (!(other instanceof SearchSetting))
                return false;
            SearchSetting o = (SearchSetting) other;
            return o.caseSensitive == this.caseSensitive
                    && o.regexSearch == this.regexSearch
                    && o.allElements == this.allElements
                    && o.mode.equals(this.mode)
                    && o.text.equals(this.text);
        }

        @Override
        public int hashCode() {
            return text.hashCode();
        }

        public static SearchSetting readFromString(String s) {
            if (s.isEmpty())
                return null;

            SearchSetting result = new SearchSetting();

            int index = 1;

            result.mode = SearchMode.fromCode(s.charAt(0));
            if (result.mode == null) {
                result.mode = SearchMode.replace;
                index = 0;
            }

            while (index < s.length()) {
                if (s.charAt(index) == 'C') {
                    result.caseSensitive = true;
                } else if (s.charAt(index) == 'R') {
                    result.regexSearch = true;
                } else if (s.charAt(index) == 'A') {
                    result.allElements = true;
                } else if (s.charAt(index) == ' ') {
                    break;
                } else {
                    Main.warn("Unknown char in SearchSettings: " + s);
                    break;
                }
                index++;
            }

            if (index < s.length() && s.charAt(index) == ' ') {
                index++;
            }

            result.text = s.substring(index);

            return result;
        }

        public String writeToString() {
            if (text == null || text.isEmpty())
                return "";

            StringBuilder result = new StringBuilder();
            result.append(mode.getCode());
            if (caseSensitive) {
                result.append('C');
            }
            if (regexSearch) {
                result.append('R');
            }
            if (allElements) {
                result.append('A');
            }
            result.append(' ')
                  .append(text);
            return result.toString();
        }
    }

    /**
     * Refreshes the enabled state
     *
     */
    @Override
    protected void updateEnabledState() {
        setEnabled(getEditLayer() != null);
    }

    @Override
    public List<ActionParameter<?>> getActionParameters() {
        return Collections.<ActionParameter<?>>singletonList(new SearchSettingsActionParameter(SEARCH_EXPRESSION));
    }

    public static String escapeStringForSearch(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }
}
