/*
    This file is part of RouteConverter.

    RouteConverter is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    RouteConverter is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with RouteConverter; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

    Copyright (C) 2007 Christian Pesch. All Rights Reserved.
*/

package slash.navigation.converter.gui.mapview;

import slash.navigation.BaseNavigationPosition;
import slash.navigation.RouteCharacteristics;
import slash.navigation.Wgs84Position;
import slash.navigation.converter.gui.models.CharacteristicsModel;
import slash.navigation.converter.gui.models.PositionsModel;
import slash.navigation.util.Positions;
import slash.common.io.CompactCalendar;
import slash.common.io.Transfer;

import javax.swing.*;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Interface for a component that displays the positions of a route.
 *
 * @author Christian Pesch
 */

public abstract class BaseMapView implements MapView {
    protected static final Preferences preferences = Preferences.userNodeForPackage(MapView.class);
    protected static final Logger log = Logger.getLogger(MapView.class.getName());

    private static final String MAP_TYPE_PREFERENCE = "mapType";
    protected static final String SCALE_CONTROL_PREFERENCE = "scaleControl";

    private static final int MAXIMUM_POLYLINE_SEGMENT_LENGTH = preferences.getInt("maximumTrackSegmentLength", 35);
    private static final int MAXIMUM_POLYLINE_POSITION_COUNT = preferences.getInt("maximumTrackPositionCount", 1500);
    private static final int MAXIMUM_DIRECTIONS_SEGMENT_LENGTH = preferences.getInt("maximumRouteSegmentLength", 22);
    private static final int MAXIMUM_DIRECTIONS_POSITION_COUNT = preferences.getInt("maximumRoutePositionCount", 500);
    private static final int MAXIMUM_MARKER_SEGMENT_LENGTH = preferences.getInt("maximumWaypointSegmentLength", 5);
    private static final int MAXIMUM_MARKER_POSITION_COUNT = preferences.getInt("maximumWaypointPositionCount", 50);
    private static final int MAXIMUM_SELECTION_COUNT = preferences.getInt("maximumSelectionCount", 10);
    private static final int[] ZOOMLEVEL_SCALE = {
            400000000,
            200000000,
            100000000,
            50000000,
            25000000,
            12500000,
            6400000,
            3200000,
            1600000,
            800000,
            400000,
            200000,
            100000,
            50000,
            25000,
            12500,
            6400,
            3200
    };
    private static final int MAXIMUM_ZOOMLEVEL_FOR_SIGNIFICANCE_CALCULATION = 16;

    private PositionsModel positionsModel;
    private List<BaseNavigationPosition> positions;

    private ServerSocket dragListenerServerSocket;
    private Thread mapViewRouteUpdater, mapViewPositionUpdater, mapViewDragListener;

    protected final Object notificationMutex = new Object();
    protected boolean initialized = false;
    private boolean running = true, pedestrians, avoidHighways,
            haveToInitializeMapOnFirstStart = true,
            haveToRepaintImmediately = false, haveToRecenterMap = false,
            haveToUpdateRoute = false, haveToReplaceRoute = false,
            haveToUpdatePosition = false, ignoreNextZoomCallback = false;
    private final Map<Integer, BitSet> significantPositionCache = new HashMap<Integer, BitSet>(ZOOMLEVEL_SCALE.length);
    private int meters = 0, seconds = 0;

    // initialization

    public void initialize(PositionsModel positionsModel, CharacteristicsModel characteristicsModel,
                           boolean pedestrians, boolean avoidHighways) {
        initializeBrowser();
        setModel(positionsModel, characteristicsModel);
        this.pedestrians = pedestrians;
        this.avoidHighways = avoidHighways;
    }

    protected abstract void initializeBrowser();

    protected void setModel(PositionsModel positionsModel, CharacteristicsModel characteristicsModel) {
        this.positionsModel = positionsModel;

        positionsModel.addTableModelListener(new TableModelListener() {
            public void tableChanged(TableModelEvent e) {
                boolean insertOrDelete = e.getType() == TableModelEvent.INSERT || e.getType() == TableModelEvent.DELETE;
                boolean allRowsChanged = e.getFirstRow() == 0 && e.getLastRow() == Integer.MAX_VALUE;
                // used to be limited to single rows which did work reliably but with usabilty problems
                // if (e.getFirstRow() == e.getLastRow() && insertOrDelete)
                if (!allRowsChanged && insertOrDelete)
                    updateButDontRecenter();
                else
                    update(allRowsChanged || insertOrDelete);
            }
        });
        characteristicsModel.addListDataListener(new ListDataListener() {
            public void intervalAdded(ListDataEvent e) {
            }
            public void intervalRemoved(ListDataEvent e) {
            }
            public void contentsChanged(ListDataEvent e) {
                update(true);
            }
        });
    }

    private Throwable initializationCause = null;

    public Throwable getInitializationCause() {
        return initializationCause;
    }

    protected void setInitializationCause(Throwable initializationCause) {
        this.initializationCause = initializationCause;
    }

    public boolean isInitialized() {
        synchronized (notificationMutex) {
            return initialized;
        }
    }

    protected void initializeBrowserInteraction() {
        getComponent().addComponentListener(new ComponentListener() {
            public void componentResized(ComponentEvent e) {
                resize();
            }

            public void componentMoved(ComponentEvent e) {
            }

            public void componentShown(ComponentEvent e) {
            }

            public void componentHidden(ComponentEvent e) {
            }
        });

        mapViewRouteUpdater = new Thread(new Runnable() {
            public void run() {
                long lastTime = 0;
                boolean recenter;
                while (true) {
                    List<BaseNavigationPosition> copiedPositions;
                    synchronized (notificationMutex) {
                        try {
                            notificationMutex.wait(1000);
                        } catch (InterruptedException e) {
                            // ignore this
                        }

                        if (!running)
                            return;
                        if (!hasPositions())
                            continue;
                        if (!isVisible())
                            continue;

                        /*
                           Update conditions:

                           - new route was loaded
                             - clear cache
                             - center map
                             - set zoom level according to route bounds
                             - repaint immediately
                           - user has moved position
                             - clear cache
                             - stay on current zoom level
                             - center map to position
                             - repaint
                           - user has removed position
                             - clear cache
                             - stay on current zoom level
                             - repaint
                           - user has zoomed map
                             - repaint if zooming into the map as it reveals more details
                           - user has moved map
                             - repaint if moved
                         */
                        long currentTime = System.currentTimeMillis();
                        if (haveToRepaintImmediately ||
                            haveToReplaceRoute ||
                            (haveToUpdateRoute && (currentTime - lastTime > 5 * 1000))) {
                            copiedPositions = filterPositionsWithoutCoordinates(positions);
                            recenter = haveToReplaceRoute;
                            haveToUpdateRoute = false;
                            haveToReplaceRoute = false;
                            haveToRepaintImmediately = false;
                        } else
                            continue;
                    }

                    copiedPositions = reducePositions(copiedPositions, recenter, getMaximumPositionCount());
                    setCenterOfMap(copiedPositions, recenter);
                    switch (positionsModel.getRoute().getCharacteristics()) {
                        case Route:
                            addDirectionsToMap(copiedPositions);
                            break;
                        case Waypoints:
                            addMarkersToMap(copiedPositions);
                            break;
                        default:
                            addPolylinesToMap(copiedPositions);
                    }
                    log.info("MapView updated for " + copiedPositions.size() + " positions of type " +
                            positionsModel.getRoute().getCharacteristics() + ", recentering: " + recenter);
                    lastTime = System.currentTimeMillis();
                }
            }
        }, "MapViewRouteUpdater");
        mapViewRouteUpdater.start();

        mapViewPositionUpdater = new Thread(new Runnable() {
            public void run() {
                long lastTime = 0;
                while (true) {
                    int[] copiedSelectedPositions;
                    List<BaseNavigationPosition> copiedPositions;
                    synchronized (notificationMutex) {
                        try {
                            notificationMutex.wait(100);
                        } catch (InterruptedException e) {
                            // ignore this
                        }

                        if (!running)
                            return;
                        if (!hasPositions())
                            continue;
                        if (!isVisible())
                            continue;

                        long currentTime = System.currentTimeMillis();
                        if (haveToRecenterMap ||
                            (haveToUpdatePosition && (currentTime - lastTime > 500))) {
                            haveToRecenterMap = false;
                            haveToUpdatePosition = false;
                            copiedSelectedPositions = new int[selectedPositionIndices.length];
                            System.arraycopy(selectedPositionIndices, 0, copiedSelectedPositions, 0, copiedSelectedPositions.length);
                            copiedPositions = filterPositionsWithoutCoordinates(positions);
                        } else
                            continue;
                    }

                    List<BaseNavigationPosition> selected = filterSelectedPositions(copiedPositions, copiedSelectedPositions);
                    selectPositions(selected);
                    log.info("MapView position updated for " + selected.size() + " positions");
                    lastTime = System.currentTimeMillis();
                }
            }
        }, "MapViewPositionUpdater");
        mapViewPositionUpdater.start();
    }

    private ServerSocket createDragListenerServerSocket() {
        try {
            ServerSocket serverSocket = new ServerSocket(0);
            serverSocket.setSoTimeout(1000);
            int port = serverSocket.getLocalPort();
            log.info("MapView listens on port " + port + " for dragging");
            setDragListenerPort(port);
            return serverSocket;
        } catch (IOException e) {
            log.severe("Cannot open drag listener socket: " + e.getMessage());
            return null;
        }
    }

    protected void initializeDragListener() {
        dragListenerServerSocket = createDragListenerServerSocket();
        if (dragListenerServerSocket == null)
            return;

        mapViewDragListener = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    synchronized (notificationMutex) {
                        if (!running) {
                            return;
                        }
                    }

                    Socket clientSocket = null;
                    BufferedReader is = null;
                    OutputStream os = null;
                    try {
                        List<String> lines = new ArrayList<String>();
                        clientSocket = dragListenerServerSocket.accept();
                        is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                        os = clientSocket.getOutputStream();
                        while (true) {
                            try {
                                String line = is.readLine();
                                if (Transfer.trim(line) == null)
                                    break;
                                lines.add(line);
                            } catch (IOException e) {
                                log.severe("Cannot read line from drag listener port:" + e.getMessage());
                                break;
                            }
                        }
                        processDragListenerCallBack(lines);
                    } catch (SocketTimeoutException e) {
                        // intentionally left empty
                    } catch (IOException e) {
                        synchronized (notificationMutex) {
                            if (running) {
                                log.severe("Cannot listen at drag listener socket: " + e.getMessage());
                            }
                        }
                        break;
                    }
                    finally {
                        try {
                            if (is != null)
                                is.close();
                            if (os != null)
                                os.close();
                            if (clientSocket != null)
                                clientSocket.close();
                        } catch (IOException e) {
                            log.severe("Cannot close drag listener socket: " + e.getMessage());
                        }
                    }
                }
            }
        }, "MapViewDragListener");
        mapViewDragListener.start();
    }

    protected void checkLocalhostResolution() {
        try {
            InetAddress localhost = InetAddress.getByName("localhost");
            log.info("localhost is resolved to: " + localhost);
            String localhostName = localhost.getHostAddress();
            log.info("IP of localhost is: " + localhostName);
            if(!localhostName.equals("127.0.0.1"))
                throw new Exception("localhost does not resolve to 127.0.0.1");

            InetAddress ip = InetAddress.getByAddress(new byte[]{127, 0, 0, 1});
            log.info("127.0.0.1 is resolved to: " + ip);
            String ipName = localhost.getHostName();
            log.info("Name of 127.0.0.1 is: " + ipName);
            if(!ipName.equals("localhost"))
                throw new Exception("127.0.0.1 does not resolve to localhost");
        } catch (Exception e) {
            e.printStackTrace();
            final String message = "Probably faulty network setup: " + e.getMessage() + ".\nPlease check your network settings.";
            log.severe(message);
            SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    JOptionPane.showMessageDialog(getComponent(), message, "Error", JOptionPane.ERROR_MESSAGE);
                }
            });
        }
    }

    protected void checkCallback() {
        final Boolean[] receivedCallback = new Boolean[1];
        receivedCallback[0] = false;

        final MapViewListener callbackWaiter = new MapViewListener() {
            public void calculatedDistance(int meters, int seconds) {
            }

            public void receivedCallback(int port) {
                synchronized (receivedCallback) {
                    receivedCallback[0] = true;
                    receivedCallback.notifyAll();
                }
            }
        };

        new Thread(new Runnable() {
            public void run() {
                addMapViewListener(callbackWaiter);
                try {
                    executeScript("checkCallback();");

                    long start = System.currentTimeMillis();
                    while(true) {
                        synchronized (receivedCallback) {
                            if(receivedCallback[0])
                                break;
                        }

                        if (start + 5000 < System.currentTimeMillis())
                            break;

                        try {
                            Thread.sleep(50);
                        } catch (InterruptedException e) {
                            // intentionally left empty
                        }
                    }

                    synchronized (receivedCallback) {
                        if(!receivedCallback[0])
                            SwingUtilities.invokeLater(new Runnable() {
                                public void run() {
                                    String message = "Unable to call RouteConverter from Webbrowser via port " + dragListenerServerSocket.getLocalPort() + ".\nPlease check your firewall settings.";
                                    log.severe(message);
                                    JOptionPane.showMessageDialog(getComponent(), message, "Error", JOptionPane.ERROR_MESSAGE);
                                }
                            });
                    }
                }
                finally {
                    removeMapViewListener(callbackWaiter);
                }
            }
        } , "CallbackChecker").start();
    }

    // disposal

    public void dispose() {
        long start = System.currentTimeMillis();
        synchronized (notificationMutex) {
            running = false;
            notificationMutex.notifyAll();
        }

        if (mapViewPositionUpdater != null) {
            try {
                mapViewPositionUpdater.join();
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = System.currentTimeMillis();
            log.info("MapViewPositionUpdater stopped after " + (end - start) + " ms");
        }

        if (mapViewRouteUpdater != null) {
            try {
                mapViewRouteUpdater.join();
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = System.currentTimeMillis();
            log.info("MapViewRouteUpdater stopped after " + (end - start) + " ms");
        }

        disposeBrowser();

        if (dragListenerServerSocket != null) {
            try {
                dragListenerServerSocket.close();
            } catch (IOException e) {
                log.severe("Cannot close drag listener socket:" + e.getMessage());
            }
            long end = System.currentTimeMillis();
            log.info("MapViewDragListenerSocket stopped after " + (end - start) + " ms");
        }

        if (mapViewDragListener != null) {
            try {
                mapViewDragListener.join();
            } catch (InterruptedException e) {
                // intentionally left empty
            }
            long end = System.currentTimeMillis();
            log.info("MapViewDragListener stopped after " + (end - start) + " ms");
        }
    }

    protected abstract void disposeBrowser();

    // getter and setter

    private int getMaximumPositionCount() {
        switch (positionsModel.getRoute().getCharacteristics()) {
            case Route:
                return MAXIMUM_DIRECTIONS_POSITION_COUNT;
            case Waypoints:
                return MAXIMUM_MARKER_POSITION_COUNT;
            default:
                return MAXIMUM_POLYLINE_POSITION_COUNT;
        }
    }

    protected boolean isVisible() {
        return getComponent().getWidth() > 0;
    }

    private boolean hasPositions() {
        synchronized (notificationMutex) {
            return isInitialized() && positions != null;
        }
    }

    private void setDragListenerPort(int dragListenerPort) {
        synchronized (notificationMutex) {
            executeScript("setDragListenerPort(" + dragListenerPort + ")");
        }
    }

    public void setSelectedPositions(int[] selectedPositions) {
        synchronized (notificationMutex) {
            this.selectedPositionIndices = selectedPositions;
            haveToUpdatePosition = true;
            notificationMutex.notifyAll();
        }
    }

    public void setPedestrians(boolean pedestrians) {
        this.pedestrians = pedestrians;
        if(positionsModel.getRoute().getCharacteristics() == RouteCharacteristics.Route)
            update(false);
    }

    public void setAvoidHighways(boolean avoidHighways) {
        this.avoidHighways = avoidHighways;
        if(positionsModel.getRoute().getCharacteristics() == RouteCharacteristics.Route)
            update(false);
    }

    public BaseNavigationPosition getCenter() {
        BaseNavigationPosition northEast = getNorthEastBounds();
        BaseNavigationPosition southWest = getSouthWestBounds();
        return northEast != null && southWest != null ? Positions.center(Arrays.asList(northEast, southWest)) : null;
    }

    protected abstract BaseNavigationPosition getNorthEastBounds();
    protected abstract BaseNavigationPosition getSouthWestBounds();

    protected BaseNavigationPosition getBounds(String script) {
        String result = executeScriptWithResult(script);
        if (result == null)
            return null;

        StringTokenizer tokenizer = new StringTokenizer(result, ",");
        if (tokenizer.countTokens() != 2)
            return null;

        String latitude = tokenizer.nextToken();
        String longitude = tokenizer.nextToken();
        return new Wgs84Position(Double.parseDouble(longitude), Double.parseDouble(latitude), null, null, null, null);
    }

    // reduction of positions

    private BitSet calculateSignificantPositionsForZoomLevel(List<BaseNavigationPosition> positions, int zoomLevel) {
        BitSet significant = significantPositionCache.get(zoomLevel);
        if (significant == null) {
            significant = new BitSet(positions.size());

            if (zoomLevel <= MAXIMUM_ZOOMLEVEL_FOR_SIGNIFICANCE_CALCULATION) {
                double threshold = ZOOMLEVEL_SCALE[zoomLevel] / 2500.0;
                long start = System.currentTimeMillis();
                int[] significantPositions = Positions.getSignificantPositions(positions, threshold);
                long end = System.currentTimeMillis();
                log.info("zoomLevel " + zoomLevel + " < " + MAXIMUM_ZOOMLEVEL_FOR_SIGNIFICANCE_CALCULATION + " threshold " + threshold + " significant positions " + significantPositions.length + " calculated in " + (end - start) + " milliseconds");
                for (int significantPosition : significantPositions)
                    significant.set(significantPosition);
            } else {
                // on all zoom level about MAXIMUM_ZOOMLEVEL_FOR_SIGNIFICANCE_CALCULATION
                // use all positions since the calculation is too expensive
                log.info("zoomLevel " + zoomLevel + " use all " + positions.size() + "positions");
                significant.set(0, positions.size(), true);
            }
            significantPositionCache.put(zoomLevel, significant);
        }
        return significant;
    }

    private List<BaseNavigationPosition> reducePositions(List<BaseNavigationPosition> positions, boolean recenter, int maximumPositionCount) {
        if (positions.size() < 2)
            return positions;

        // determine significant positions for this zoom level
        positions = filterSignificantPositions(positions, recenter);

        // reduce the number of significant positions by a visibility heuristic
        if (positions.size() > maximumPositionCount)
            positions = filterVisiblePositions(positions);

        // reduce the number of visible positions by a JS-stability heuristic
        if (positions.size() > maximumPositionCount)
            positions = filterEveryNthPosition(positions, maximumPositionCount);

        return positions;
    }

    private List<BaseNavigationPosition> filterSignificantPositions(List<BaseNavigationPosition> positions, boolean recenter) {
        int zoomLevel = recenter ? getBoundsZoomLevel(positions) : getCurrentZoomLevel();

        BitSet pointStatus = calculateSignificantPositionsForZoomLevel(positions, zoomLevel);

        List<BaseNavigationPosition> result = new ArrayList<BaseNavigationPosition>();
        for (int i = 0; i < positions.size(); i++)
            if (pointStatus.get(i))
                result.add(positions.get(i));
        return result;
    }

    protected abstract int getBoundsZoomLevel(List<BaseNavigationPosition> positions);
    protected abstract int getCurrentZoomLevel();

    private List<BaseNavigationPosition> filterVisiblePositions(List<BaseNavigationPosition> positions) {
        BaseNavigationPosition northEast = getNorthEastBounds();
        BaseNavigationPosition southWest = getSouthWestBounds();
        if (northEast == null || southWest == null)
            return positions;

        // heuristic: increase bounds for visible positions to enable dragging the map
        // at the same zoom level, with a factor of 2 you hardly see the cropping even
        // with a small map and a big screen (meaning lots of space to drag the map)
        double width = (northEast.getLongitude() - southWest.getLongitude()) * 2.0;
        double height = (southWest.getLatitude() - northEast.getLatitude()) * 2.0;
        northEast.setLongitude(northEast.getLongitude() + width);
        northEast.setLatitude(northEast.getLatitude() - height);
        southWest.setLongitude(southWest.getLongitude() - width);
        southWest.setLatitude(southWest.getLatitude() + height);

        List<BaseNavigationPosition> result = new ArrayList<BaseNavigationPosition>();
        for (BaseNavigationPosition position : positions) {
            if (Positions.contains(northEast, southWest, position)) {
                result.add(position);
            }
        }

        log.info("filtered visible positions to reduce " + positions.size() + " positions to " + result.size());
        return result;
    }

    private List<BaseNavigationPosition> filterEveryNthPosition(List<BaseNavigationPosition> positions, int maximumPositionCount) {
        List<BaseNavigationPosition> result = new ArrayList<BaseNavigationPosition>();
        result.add(positions.get(0));

        double increment = positions.size() / (double) maximumPositionCount;
        for (double i = 1; i < positions.size() - 1; i += increment) {
            result.add(positions.get((int) i));
        }

        result.add(positions.get(positions.size() - 1));

        log.info("filtered every " + increment + "th position to reduce " + positions.size() + " positions to " + result.size() + " (maximum was " + maximumPositionCount + ")");
        return result;
    }

    private List<BaseNavigationPosition> filterSelectedPositions(List<BaseNavigationPosition> positions, int[] selectedIndices) {
        List<BaseNavigationPosition> result = new ArrayList<BaseNavigationPosition>();
        for (int selectedIndex : selectedIndices) {
            if (selectedIndex >= positions.size())
                continue;
            result.add(positions.get(selectedIndex));
        }
        return result;
    }

    private List<BaseNavigationPosition> filterPositionsWithoutCoordinates(List<BaseNavigationPosition> positions) {
        List<BaseNavigationPosition> result = new ArrayList<BaseNavigationPosition>();
        // copy to avoid ConcurrentModificationException
        positions = new ArrayList<BaseNavigationPosition>(positions);
        for (BaseNavigationPosition position : positions) {
            if (position.hasCoordinates())
                result.add(position);
        }
        return result;
    }

    // draw on map

    protected void update(boolean haveToReplaceRoute) {
        if (!isInitialized() || !getComponent().isShowing())
            return;

        synchronized (notificationMutex) {
            this.positions = positionsModel.getRoute() != null ? positionsModel.getRoute().getPositions() : null;
            this.haveToUpdateRoute = true;
            if (haveToReplaceRoute) {
                this.haveToReplaceRoute = true;
                this.haveToUpdatePosition = true;
                significantPositionCache.clear();
            }
            log.fine(System.currentTimeMillis() + " haveToUpdateRoute: " + haveToUpdateRoute +
                    " haveToReplaceRoute: " + haveToReplaceRoute + " positions: " + positions);
            notificationMutex.notifyAll();
        }
    }

    private void updateButDontRecenter() {
        // repaint route immediately, simulates update(true) without recentering
        synchronized (notificationMutex) {
            haveToRepaintImmediately = true;
            significantPositionCache.clear();
            notificationMutex.notifyAll();
        }
    }

    private void removeOverlays() {
        executeScript("removeOverlays();\nremoveDirections();");
    }

    private void addDirectionsToMap(List<BaseNavigationPosition> positions) {
        meters = 0;
        seconds = 0;

        // avoid throwing javascript exceptions if there is nothing to direct
        if(positions.size() < 2) {
            addMarkersToMap(positions);
            return;
        }

        int directionsCount = Transfer.ceiling(positions.size(), MAXIMUM_DIRECTIONS_SEGMENT_LENGTH, false);
        for (int j = 0; j < directionsCount; j++) {
            StringBuffer buffer = new StringBuffer();
            buffer.append("var latlngs = [");
            int maximum = Math.min(positions.size(), (j + 1) * MAXIMUM_DIRECTIONS_SEGMENT_LENGTH + 1);
            for (int i = j * MAXIMUM_DIRECTIONS_SEGMENT_LENGTH; i < maximum; i++) {
                BaseNavigationPosition position = positions.get(i);
                buffer.append("new GLatLng(").append(position.getLatitude()).append(",").append(position.getLongitude()).append(")");
                if (i < maximum - 1)
                    buffer.append(",");
            }
            buffer.append("];\n");
            buffer.append("createDirections().loadFromWaypoints(latlngs, ").
                   append("{ preserveViewport: true, getPolyline: true, avoidHighways: ").
                    append(avoidHighways).append(", travelMode: ").
                    append(pedestrians ? "G_TRAVEL_MODE_WALKING" : "G_TRAVEL_MODE_DRIVING").
                    append(", locale: '").append(Locale.getDefault()).append("'").
                    append(" });");
            executeScript(buffer.toString());
        }
        removeOverlays();
    }

    private void addPolylinesToMap(final List<BaseNavigationPosition> positions) {
        int polylinesCount = Transfer.ceiling(positions.size(), MAXIMUM_POLYLINE_SEGMENT_LENGTH, true);
        for (int j = 0; j < polylinesCount; j++) {
            StringBuffer buffer = new StringBuffer();
            buffer.append("var latlngs = [");
            int maximum = Math.min(positions.size(), (j + 1) * MAXIMUM_POLYLINE_SEGMENT_LENGTH + 1);
            for (int i = j * MAXIMUM_POLYLINE_SEGMENT_LENGTH; i < maximum; i++) {
                BaseNavigationPosition position = positions.get(i);
                buffer.append("new GLatLng(").append(position.getLatitude()).append(",").append(position.getLongitude()).append(")");
                if (i < maximum - 1)
                    buffer.append(",");
            }
            buffer.append("];\n");
            buffer.append("addOverlay(new GPolyline(latlngs,\"#0033FF\",2,1));");
            executeScript(buffer.toString());
        }
        removeOverlays();

        new Thread(new Runnable() {
            public void run() {
                calculatedDistance(0, 0);

                int meters = 0;
                long delta = 0;
                Calendar minimumTime = null, maximumTime = null;
                BaseNavigationPosition previous = null;
                for (int i = 0; i < positions.size(); i++) {
                    BaseNavigationPosition next = positions.get(i);
                    if (previous != null) {
                        Double distance = previous.calculateDistance(next);
                        if (distance != null)
                            meters += distance;
                        Long time = previous.calculateTime(next);
                        if (time != null)
                            delta += time;
                    }

                    CompactCalendar time = next.getTime();
                    if (time != null) {
                        Calendar calendar = time.getCalendar();
                        if (minimumTime == null || calendar.before(minimumTime))
                            minimumTime = calendar;
                        if (maximumTime == null || calendar.after(maximumTime))
                            maximumTime = calendar;
                    }

                    if (i % 100 == 0)
                        calculatedDistance(meters, delta > 0 ? (int) (delta / 1000) : 0);

                    previous = next;
                }

                int summedUp = delta > 0 ? (int) delta / 1000 : 0;
                int maxMinusMin = minimumTime != null ? (int) ((maximumTime.getTimeInMillis() - minimumTime.getTimeInMillis()) / 1000) : 0;
                calculatedDistance(meters, Math.max(maxMinusMin, summedUp));
            }
        }, "PolylineDistanceCalculator").start();
    }

    private void addMarkersToMap(List<BaseNavigationPosition> positions) {
        int markersCount = Transfer.ceiling(positions.size(), MAXIMUM_MARKER_SEGMENT_LENGTH, false);
        for (int j = 0; j < markersCount; j++) {
            StringBuffer buffer = new StringBuffer();

            int maximum = Math.min(positions.size(), (j + 1) * MAXIMUM_MARKER_SEGMENT_LENGTH);
            for (int i = j * MAXIMUM_MARKER_SEGMENT_LENGTH; i < maximum; i++) {
                BaseNavigationPosition position = positions.get(i);
                buffer.append("var marker = new GMarker(new GLatLng(").
                        append(position.getLatitude()).append(",").append(position.getLongitude()).
                        append("), { title: \"").append(escape(position.getComment())).append("\", ").
                        append("clickable: false, icon: markerIcon });\n");
                buffer.append("addOverlay(marker);\n");
            }
            executeScript(buffer.toString());
        }
        removeOverlays();
    }

    private void setCenterOfMap(List<BaseNavigationPosition> positions, boolean recenter) {
        StringBuffer buffer = new StringBuffer();
        // set map type only on first start
        if (haveToInitializeMapOnFirstStart) {
            String mapType = preferences.get(MAP_TYPE_PREFERENCE, "Map");
            buffer.append("setMapType(\"").append(mapType).append("\");\n");
        }

        // if there are positions center on first start or if we have to recenter
        if (positions.size() > 0 && (haveToInitializeMapOnFirstStart || recenter)) {
            Wgs84Position northEast = Positions.northEast(positions);
            Wgs84Position southWest = Positions.southWest(positions);
            buffer.append("var zoomLevel = map.getBoundsZoomLevel(new GLatLngBounds(").
                    append("new GLatLng(").append(northEast.getLatitude()).append(",").append(northEast.getLongitude()).append("),").
                    append("new GLatLng(").append(southWest.getLatitude()).append(",").append(southWest.getLongitude()).append(")").
                    append("));\n");
            Wgs84Position center = Positions.center(positions);
            buffer.append("map.setCenter(new GLatLng(").append(center.getLatitude()).append(",").
                    append(center.getLongitude()).append("), zoomLevel);");
            ignoreNextZoomCallback = true;
        }
        executeScript(buffer.toString());
        haveToInitializeMapOnFirstStart = false;
    }

    private int lastSelectedPositionCount = -1;
    private List<BaseNavigationPosition> lastSelectedPositions;
    private int[] selectedPositionIndices = new int[0];

    private void selectPositions(List<BaseNavigationPosition> selectedPositions) {
        if (selectedPositions.size() > MAXIMUM_SELECTION_COUNT)
            selectedPositions = selectedPositions.subList(0, MAXIMUM_SELECTION_COUNT);

        // delete old
        if (lastSelectedPositionCount >= 0) {
            StringBuffer buffer = new StringBuffer();
            for (int i = 0; i < lastSelectedPositionCount; i++) {
                buffer.append("map.removeOverlay(selected").append(i).append(");\n");
            }
            executeScript(buffer.toString());
        }

        // build up new
        StringBuffer buffer = new StringBuffer();
        lastSelectedPositionCount = selectedPositions.size();
        lastSelectedPositions = selectedPositions;
        for (int i = 0; i < lastSelectedPositionCount; i++) {
            BaseNavigationPosition selectedPosition = selectedPositions.get(i);
            buffer.append("var selected").append(i).append(" = ");
            buffer.append("new GMarker(new GLatLng(").append(selectedPosition.getLatitude()).append(",")
                    .append(selectedPosition.getLongitude()).append("), { title: \"")
                    .append(escape(selectedPosition.getComment())).append("\", draggable: true });\n");
            buffer.append("addMarker(selected").append(i).append(",").append(i).append(");\n");
        }

        // pan to first position
        if (lastSelectedPositionCount > 0) {
            BaseNavigationPosition center = selectedPositions.get(0);
            buffer.append("centerMap(new GLatLng(").append(center.getLatitude()).append(",").
                    append(center.getLongitude()).append("));");
        }
        executeScript(buffer.toString());
    }

    // call Google Maps API functions

    public void print(boolean withRoute) {
        executeScript("printMap(" + withRoute + ");");
    }

    // script execution

    private String escape(String string) {
        if(string == null)
            return "";
        StringBuffer buffer = new StringBuffer(string);
        for (int i = 0; i < buffer.length(); i++) {
            char c = buffer.charAt(i);
            if (!(Character.isLetterOrDigit(c) || Character.isWhitespace(c) || c == '\'' || c == ',')) {
                buffer.deleteCharAt(i);
                i--;
            }
        }
        return buffer.toString();
    }

    protected void logExecuteScript(String script, Object result) {
        String output = System.currentTimeMillis() + " executing script '" + script + (result != null ? "' with result '" + result : "") + "'";
        log.fine(output);
    }

    protected abstract void executeScript(String script);
    protected abstract String executeScriptWithResult(String script);

    // browser callbacks

    private static final Pattern DRAG_END_PATTERN = Pattern.compile("^GET /dragend/(.*)/(.*)/(.*) .*$");
    private static final Pattern DIRECTIONS_LOAD_PATTERN = Pattern.compile("^GET /load/(\\d*)/(\\d*) .*$");
    private static final Pattern MAP_TYPE_CHANGED_PATTERN = Pattern.compile("^GET /maptypechanged/(.*) .*$");
    private static final Pattern ZOOM_END_PATTERN = Pattern.compile("^GET /zoomend/(.*)/(.*) .*$");
    private static final Pattern MOVE_END_PATTERN = Pattern.compile("^GET /moveend/(.*)/(.*) .*$");
    private static final Pattern CALLBACK_PATTERN = Pattern.compile("^GET /callback/(\\d+) .*$");

    private void processDragListenerCallBack(List<String> lines) {
        if (!isAuthenticated(lines))
            return;

        for (String line : lines) {
            Matcher dragEndMatcher = DRAG_END_PATTERN.matcher(line);
            if (dragEndMatcher.matches()) {
                int index = Transfer.parseInt(dragEndMatcher.group(1));
                Double latitude = Transfer.parseDouble(dragEndMatcher.group(2));
                Double longitude = Transfer.parseDouble(dragEndMatcher.group(3));
                movedPosition(index, longitude, latitude);
            }

            Matcher directionsLoadMatcher = DIRECTIONS_LOAD_PATTERN.matcher(line);
            if (directionsLoadMatcher.matches()) {
                meters += Transfer.parseInt(directionsLoadMatcher.group(1));
                seconds += Transfer.parseInt(directionsLoadMatcher.group(2));
                calculatedDistance(meters, seconds);
            }

            Matcher mapTypeChangedMatcher = MAP_TYPE_CHANGED_PATTERN.matcher(line);
            if (mapTypeChangedMatcher.matches()) {
                String mapType = mapTypeChangedMatcher.group(1);
                preferences.put(MAP_TYPE_PREFERENCE, mapType);
            }

            Matcher zoomEndMatcher = ZOOM_END_PATTERN.matcher(line);
            if (zoomEndMatcher.matches()) {
                int startZoomLevel = Transfer.parseInt(zoomEndMatcher.group(1));
                int endZoomLevel = Transfer.parseInt(zoomEndMatcher.group(2));

                synchronized (notificationMutex) {
                    // since setCenter() leads to a callback and thus paints the track twice
                    if (ignoreNextZoomCallback)
                        ignoreNextZoomCallback = false;
                    else
                        // only repaint immediately if the users zooms into the map and needs more details
                        haveToRepaintImmediately = startZoomLevel < endZoomLevel;
                    haveToRecenterMap = true;
                    notificationMutex.notifyAll();
                }
            }

            Matcher moveEndMather = MOVE_END_PATTERN.matcher(line);
            if (moveEndMather.matches()) {
                if (getCurrentZoomLevel() >= MAXIMUM_ZOOMLEVEL_FOR_SIGNIFICANCE_CALCULATION) {
                    synchronized (notificationMutex) {
                        haveToRepaintImmediately = true;
                        notificationMutex.notifyAll();
                    }
                }
            }

            Matcher testMatcher = CALLBACK_PATTERN.matcher(line);
            if (testMatcher.matches()) {
                int port = Transfer.parseInt(testMatcher.group(1));
                receivedCallback(port);
            }
        }
    }

    private void movedPosition(int index, Double longitude, Double latitude) {
        BaseNavigationPosition modify = lastSelectedPositions.get(index);
        modify.setLatitude(latitude);
        modify.setLongitude(longitude);

        updateButDontRecenter();

        // notify views about change, leads to update(false)
        int row;
        synchronized (notificationMutex) {
           row = positions.indexOf(modify);
        }
        // updating all rows behind the modified is quite expensive, but necessary due to the distance
        // calculation - if that didn't exist the single update of row would be sufficient
        positionsModel.fireTableRowsUpdated(row, positions.size() - 1);

        // give time for repainting of the route
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            // intentionally left empty
        }

        // repaint position marker
        synchronized (notificationMutex) {
            haveToUpdatePosition = true;
            notificationMutex.notifyAll();
        }
    }

    private boolean isAuthenticated(List<String> lines) {
        Map<String, String> map = asMap(lines);
        String host = Transfer.trim(map.get("Host"));
        String id = Transfer.trim(map.get("id"));
        return host != null && host.equals("localhost:" + dragListenerServerSocket.getLocalPort()) &&
                id != null && id.equals("Jx3dQUv4");
    }

    private static final Pattern NAME_VALUE_PATTERN = Pattern.compile("^(.+?):(.+)$");

    private Map<String, String> asMap(List<String> lines) {
        Map<String, String> map = new HashMap<String, String>();
        for (String line : lines) {
            Matcher matcher = NAME_VALUE_PATTERN.matcher(line);
            if (matcher.matches())
                map.put(matcher.group(1), matcher.group(2));
        }
        return map;
    }

    // listeners

    private final List<MapViewListener> mapViewListeners = new CopyOnWriteArrayList<MapViewListener>();

    public void addMapViewListener(MapViewListener listener) {
        mapViewListeners.add(listener);
    }

    public void removeMapViewListener(MapViewListener listener) {
        mapViewListeners.remove(listener);
    }

    private void calculatedDistance(int meters, int seconds) {
        for (MapViewListener listener : mapViewListeners) {
            listener.calculatedDistance(meters, seconds);
        }
    }

    private void receivedCallback(int port) {
        for (MapViewListener listener : mapViewListeners) {
            listener.receivedCallback(port);
        }
    }
}
