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

import static org.openstreetmap.josm.tools.I18n.tr;

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;

import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.DefaultListSelectionModel;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableColumnModel;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableCellRenderer;
import javax.swing.table.TableColumn;

import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.Bounds;
import org.openstreetmap.josm.gui.ExceptionDialogUtil;
import org.openstreetmap.josm.gui.HelpAwareOptionPane;
import org.openstreetmap.josm.gui.PleaseWaitRunnable;
import org.openstreetmap.josm.gui.util.GuiHelper;
import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
import org.openstreetmap.josm.gui.widgets.JosmComboBox;
import org.openstreetmap.josm.io.OsmTransferException;
import org.openstreetmap.josm.tools.GBC;
import org.openstreetmap.josm.tools.ImageProvider;
import org.openstreetmap.josm.tools.OsmUrlToBounds;
import org.openstreetmap.josm.tools.Utils;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

public class PlaceSelection implements DownloadSelection {
    private static final String HISTORY_KEY = "download.places.history";

    private HistoryComboBox cbSearchExpression;
    private NamedResultTableModel model;
    private NamedResultTableColumnModel columnmodel;
    private JTable tblSearchResults;
    private DownloadDialog parent;
    private static final Server[] SERVERS = new Server[] {
        new Server("Nominatim", "https://nominatim.openstreetmap.org/search?format=xml&q=", tr("Class Type"), tr("Bounds"))
    };
    private final JosmComboBox<Server> server = new JosmComboBox<>(SERVERS);

    private static class Server {
        public final String name;
        public final String url;
        public final String thirdcol;
        public final String fourthcol;

        public Server(String n, String u, String t, String f) {
            name = n;
            url = u;
            thirdcol = t;
            fourthcol = f;
        }

        @Override
        public String toString() {
            return name;
        }
    }

    protected JPanel buildSearchPanel() {
        JPanel lpanel = new JPanel();
        lpanel.setLayout(new GridLayout(2, 2));
        JPanel panel = new JPanel();
        panel.setLayout(new GridBagLayout());

        lpanel.add(new JLabel(tr("Choose the server for searching:")));
        lpanel.add(server);
        String s = Main.pref.get("namefinder.server", SERVERS[0].name);
        for (int i = 0; i < SERVERS.length; ++i) {
            if (SERVERS[i].name.equals(s)) {
                server.setSelectedIndex(i);
            }
        }
        lpanel.add(new JLabel(tr("Enter a place name to search for:")));

        cbSearchExpression = new HistoryComboBox();
        cbSearchExpression.setToolTipText(tr("Enter a place name to search for"));
        List<String> cmtHistory = new LinkedList<>(Main.pref.getCollection(HISTORY_KEY, new LinkedList<String>()));
        Collections.reverse(cmtHistory);
        cbSearchExpression.setPossibleItems(cmtHistory);
        lpanel.add(cbSearchExpression);

        panel.add(lpanel, GBC.std().fill(GBC.HORIZONTAL).insets(5, 5, 0, 5));
        SearchAction searchAction = new SearchAction();
        JButton btnSearch = new JButton(searchAction);
        ((JTextField) cbSearchExpression.getEditor().getEditorComponent()).getDocument().addDocumentListener(searchAction);
        ((JTextField) cbSearchExpression.getEditor().getEditorComponent()).addActionListener(searchAction);

        panel.add(btnSearch, GBC.eol().insets(5, 5, 0, 5));

        return panel;
    }

    /**
     * Adds a new tab to the download dialog in JOSM.
     *
     * This method is, for all intents and purposes, the constructor for this class.
     */
    @Override
    public void addGui(final DownloadDialog gui) {
        JPanel panel = new JPanel();
        panel.setLayout(new BorderLayout());
        panel.add(buildSearchPanel(), BorderLayout.NORTH);

        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
        model = new NamedResultTableModel(selectionModel);
        columnmodel = new NamedResultTableColumnModel();
        tblSearchResults = new JTable(model, columnmodel);
        tblSearchResults.setSelectionModel(selectionModel);
        JScrollPane scrollPane = new JScrollPane(tblSearchResults);
        scrollPane.setPreferredSize(new Dimension(200, 200));
        panel.add(scrollPane, BorderLayout.CENTER);

        gui.addDownloadAreaSelector(panel, tr("Areas around places"));

        scrollPane.setPreferredSize(scrollPane.getPreferredSize());
        tblSearchResults.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
        tblSearchResults.getSelectionModel().addListSelectionListener(new ListSelectionHandler());
        tblSearchResults.addMouseListener(new MouseAdapter() {
            @Override public void mouseClicked(MouseEvent e) {
                if (e.getClickCount() > 1) {
                    SearchResult sr = model.getSelectedSearchResult();
                    if (sr == null) return;
                    parent.startDownload(sr.getDownloadArea());
                }
            }
        });
        parent = gui;
    }

    @Override
    public void setDownloadArea(Bounds area) {
        tblSearchResults.clearSelection();
    }

    /**
     * Data storage for search results.
     */
    private static class SearchResult {
        public String name;
        public String info;
        public String nearestPlace;
        public String description;
        public double lat;
        public double lon;
        public int zoom = 0;
        public Bounds bounds = null;

        public Bounds getDownloadArea() {
            return bounds != null ? bounds : OsmUrlToBounds.positionToBounds(lat, lon, zoom);
        }
    }

    /**
     * A very primitive parser for the name finder's output.
     * Structure of xml described here:  http://wiki.openstreetmap.org/index.php/Name_finder
     *
     */
    private static class NameFinderResultParser extends DefaultHandler {
        private SearchResult currentResult = null;
        private StringBuffer description = null;
        private int depth = 0;
        private List<SearchResult> data = new LinkedList<>();

        /**
         * Detect starting elements.
         *
         */
        @Override
        public void startElement(String namespaceURI, String localName, String qName, Attributes atts)
        throws SAXException {
            depth++;
            try {
                if ("searchresults".equals(qName)) {
                    // do nothing
                } else if ("named".equals(qName) && (depth == 2)) {
                    currentResult = new PlaceSelection.SearchResult();
                    currentResult.name = atts.getValue("name");
                    currentResult.info = atts.getValue("info");
                    if (currentResult.info != null) {
                        currentResult.info = tr(currentResult.info);
                    }
                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
                    currentResult.zoom = Integer.parseInt(atts.getValue("zoom"));
                    data.add(currentResult);
                } else if ("description".equals(qName) && (depth == 3)) {
                    description = new StringBuffer();
                } else if ("named".equals(qName) && (depth == 4)) {
                    // this is a "named" place in the nearest places list.
                    String info = atts.getValue("info");
                    if ("city".equals(info) || "town".equals(info) || "village".equals(info)) {
                        currentResult.nearestPlace = atts.getValue("name");
                    }
                } else if ("place".equals(qName) && atts.getValue("lat") != null) {
                    currentResult = new PlaceSelection.SearchResult();
                    currentResult.name = atts.getValue("display_name");
                    currentResult.description = currentResult.name;
                    currentResult.info = atts.getValue("class");
                    if (currentResult.info != null) {
                        currentResult.info = tr(currentResult.info);
                    }
                    currentResult.nearestPlace = tr(atts.getValue("type"));
                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
                    String[] bbox = atts.getValue("boundingbox").split(",");
                    currentResult.bounds = new Bounds(
                            Double.parseDouble(bbox[0]), Double.parseDouble(bbox[2]),
                            Double.parseDouble(bbox[1]), Double.parseDouble(bbox[3]));
                    data.add(currentResult);
                }
            } catch (NumberFormatException x) {
                Main.error(x); // SAXException does not chain correctly
                throw new SAXException(x.getMessage(), x);
            } catch (NullPointerException x) {
                Main.error(x); // SAXException does not chain correctly
                throw new SAXException(tr("Null pointer exception, possibly some missing tags."), x);
            }
        }

        /**
         * Detect ending elements.
         */
        @Override
        public void endElement(String namespaceURI, String localName, String qName) throws SAXException {
            if ("description".equals(qName) && description != null) {
                currentResult.description = description.toString();
                description = null;
            }
            depth--;
        }

        /**
         * Read characters for description.
         */
        @Override
        public void characters(char[] data, int start, int length) throws org.xml.sax.SAXException {
            if (description != null) {
                description.append(data, start, length);
            }
        }

        public List<SearchResult> getResult() {
            return data;
        }
    }

    class SearchAction extends AbstractAction implements DocumentListener {

        public SearchAction() {
            putValue(NAME, tr("Search ..."));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "search"));
            putValue(SHORT_DESCRIPTION, tr("Click to start searching for places"));
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            if (!isEnabled() || cbSearchExpression.getText().trim().isEmpty())
                return;
            cbSearchExpression.addCurrentItemToHistory();
            Main.pref.putCollection(HISTORY_KEY, cbSearchExpression.getHistory());
            NameQueryTask task = new NameQueryTask(cbSearchExpression.getText());
            Main.worker.submit(task);
        }

        protected final void updateEnabledState() {
            setEnabled(!cbSearchExpression.getText().trim().isEmpty());
        }

        @Override
        public void changedUpdate(DocumentEvent e) {
            updateEnabledState();
        }

        @Override
        public void insertUpdate(DocumentEvent e) {
            updateEnabledState();
        }

        @Override
        public void removeUpdate(DocumentEvent e) {
            updateEnabledState();
        }
    }

    class NameQueryTask extends PleaseWaitRunnable {

        private String searchExpression;
        private HttpURLConnection connection;
        private List<SearchResult> data;
        private boolean canceled = false;
        private Server useserver;
        private Exception lastException;

        public NameQueryTask(String searchExpression) {
            super(tr("Querying name server"), false /* don't ignore exceptions */);
            this.searchExpression = searchExpression;
            useserver = (Server) server.getSelectedItem();
            Main.pref.put("namefinder.server", useserver.name);
        }

        @Override
        protected void cancel() {
            this.canceled = true;
            synchronized (this) {
                if (connection != null) {
                    connection.disconnect();
                }
            }
        }

        @Override
        protected void finish() {
            if (canceled)
                return;
            if (lastException != null) {
                ExceptionDialogUtil.explainException(lastException);
                return;
            }
            columnmodel.setHeadlines(useserver.thirdcol, useserver.fourthcol);
            model.setData(this.data);
        }

        @Override
        protected void realRun() throws SAXException, IOException, OsmTransferException {
            String urlString = useserver.url+Utils.encodeUrl(searchExpression);

            try {
                getProgressMonitor().indeterminateSubTask(tr("Querying name server ..."));
                URL url = new URL(urlString);
                synchronized (this) {
                    connection = Utils.openHttpConnection(url);
                }
                connection.setConnectTimeout(Main.pref.getInteger("socket.timeout.connect", 15)*1000);
                try (
                    InputStream inputStream = connection.getInputStream();
                    Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
                ) {
                    InputSource inputSource = new InputSource(reader);
                    NameFinderResultParser parser = new NameFinderResultParser();
                    Utils.parseSafeSAX(inputSource, parser);
                    this.data = parser.getResult();
                }
            } catch (SAXParseException e) {
                if (!canceled) {
                    // Nominatim sometimes returns garbage, see #5934, #10643
                    Main.warn(tr("Error occured with query ''{0}'': ''{1}''", urlString, e.getMessage()));
                    GuiHelper.runInEDTAndWait(new Runnable() {
                        @Override
                        public void run() {
                            HelpAwareOptionPane.showOptionDialog(
                                    Main.parent,
                                    tr("Name server returned invalid data. Please try again."),
                                    tr("Bad response"),
                                    JOptionPane.WARNING_MESSAGE, null
                            );
                        }
                    });
                }
            } catch (Exception e) {
                if (!canceled) {
                    OsmTransferException ex = new OsmTransferException(e);
                    ex.setUrl(urlString);
                    lastException = ex;
                }
            }
        }
    }

    static class NamedResultTableModel extends DefaultTableModel {
        private transient List<SearchResult> data;
        private transient ListSelectionModel selectionModel;

        public NamedResultTableModel(ListSelectionModel selectionModel) {
            data = new ArrayList<>();
            this.selectionModel = selectionModel;
        }

        @Override
        public int getRowCount() {
            if (data == null) return 0;
            return data.size();
        }

        @Override
        public Object getValueAt(int row, int column) {
            if (data == null) return null;
            return data.get(row);
        }

        public void setData(List<SearchResult> data) {
            if (data == null) {
                this.data.clear();
            } else {
                this.data = new ArrayList<>(data);
            }
            fireTableDataChanged();
        }

        @Override
        public boolean isCellEditable(int row, int column) {
            return false;
        }

        public SearchResult getSelectedSearchResult() {
            if (selectionModel.getMinSelectionIndex() < 0)
                return null;
            return data.get(selectionModel.getMinSelectionIndex());
        }
    }

    static class NamedResultTableColumnModel extends DefaultTableColumnModel {
        private TableColumn col3 = null;
        private TableColumn col4 = null;
        protected final void createColumns() {
            TableColumn col = null;
            NamedResultCellRenderer renderer = new NamedResultCellRenderer();

            // column 0 - Name
            col = new TableColumn(0);
            col.setHeaderValue(tr("Name"));
            col.setResizable(true);
            col.setPreferredWidth(200);
            col.setCellRenderer(renderer);
            addColumn(col);

            // column 1 - Version
            col = new TableColumn(1);
            col.setHeaderValue(tr("Type"));
            col.setResizable(true);
            col.setPreferredWidth(100);
            col.setCellRenderer(renderer);
            addColumn(col);

            // column 2 - Near
            col3 = new TableColumn(2);
            col3.setHeaderValue(SERVERS[0].thirdcol);
            col3.setResizable(true);
            col3.setPreferredWidth(100);
            col3.setCellRenderer(renderer);
            addColumn(col3);

            // column 3 - Zoom
            col4 = new TableColumn(3);
            col4.setHeaderValue(SERVERS[0].fourthcol);
            col4.setResizable(true);
            col4.setPreferredWidth(50);
            col4.setCellRenderer(renderer);
            addColumn(col4);
        }

        public void setHeadlines(String third, String fourth) {
            col3.setHeaderValue(third);
            col4.setHeaderValue(fourth);
            fireColumnMarginChanged();
        }

        public NamedResultTableColumnModel() {
            createColumns();
        }
    }

    class ListSelectionHandler implements ListSelectionListener {
        @Override
        public void valueChanged(ListSelectionEvent lse) {
            SearchResult r = model.getSelectedSearchResult();
            if (r != null) {
                parent.boundingBoxChanged(r.getDownloadArea(), PlaceSelection.this);
            }
        }
    }

    static class NamedResultCellRenderer extends JLabel implements TableCellRenderer {

        /**
         * Constructs a new {@code NamedResultCellRenderer}.
         */
        public NamedResultCellRenderer() {
            setOpaque(true);
            setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2));
        }

        protected void reset() {
            setText("");
            setIcon(null);
        }

        protected void renderColor(boolean selected) {
            if (selected) {
                setForeground(UIManager.getColor("Table.selectionForeground"));
                setBackground(UIManager.getColor("Table.selectionBackground"));
            } else {
                setForeground(UIManager.getColor("Table.foreground"));
                setBackground(UIManager.getColor("Table.background"));
            }
        }

        protected String lineWrapDescription(String description) {
            StringBuilder ret = new StringBuilder();
            StringBuilder line = new StringBuilder();
            StringTokenizer tok = new StringTokenizer(description, " ");
            while (tok.hasMoreElements()) {
                String t = tok.nextToken();
                if (line.length() == 0) {
                    line.append(t);
                } else if (line.length() < 80) {
                    line.append(' ').append(t);
                } else {
                    line.append(' ').append(t).append("<br>");
                    ret.append(line);
                    line = new StringBuilder();
                }
            }
            ret.insert(0, "<html>");
            ret.append("</html>");
            return ret.toString();
        }

        @Override
        public Component getTableCellRendererComponent(JTable table, Object value,
                boolean isSelected, boolean hasFocus, int row, int column) {

            reset();
            renderColor(isSelected);

            if (value == null) return this;
            SearchResult sr = (SearchResult) value;
            switch(column) {
            case 0:
                setText(sr.name);
                break;
            case 1:
                setText(sr.info);
                break;
            case 2:
                setText(sr.nearestPlace);
                break;
            case 3:
                if (sr.bounds != null) {
                    setText(sr.bounds.toShortString(new DecimalFormat("0.000")));
                } else {
                    setText(sr.zoom != 0 ? Integer.toString(sr.zoom) : tr("unknown"));
                }
                break;
            }
            setToolTipText(lineWrapDescription(sr.description));
            return this;
        }
    }
}
