/**
 * Created by Nicholas Hallahan on 1/7/15.
 * [email protected]
 */

package com.spatialdev.osm.model;

import android.util.Log;

import com.mapbox.mapboxsdk.api.ILatLng;
import com.spatialdev.osm.marker.OSMMarker;
import com.vividsolutions.jts.geom.Coordinate;
import com.vividsolutions.jts.geom.Envelope;
import com.vividsolutions.jts.geom.Geometry;
import com.vividsolutions.jts.geom.GeometryFactory;
import com.vividsolutions.jts.geom.LineString;
import com.vividsolutions.jts.geom.Point;
import com.vividsolutions.jts.geom.Polygon;
import com.vividsolutions.jts.index.quadtree.Quadtree;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class JTSModel {

    private static final int TAP_PIXEL_TOLERANCE = 24;

    private Map<String, OSMDataSet> dataSetHash;
    private GeometryFactory geometryFactory;
    private Quadtree spatialIndex;

    public JTSModel() {
        geometryFactory = new GeometryFactory();
        spatialIndex = new Quadtree();
        dataSetHash = new ConcurrentHashMap<>();
    }

    public synchronized void addOSMDataSet(String filePath, OSMDataSet ds) {
        dataSetHash.put(filePath, ds);
        addOSMClosedWays(ds);
        addOSMOpenWays(ds);
        addOSMStandaloneNodes(ds);
    }
    
    public synchronized void mergeEditedOSMDataSet(String absPath, OSMDataSet ds) {
        Collection<OSMDataSet> dataSets = dataSetHash.values();
        for (OSMDataSet existingDataSet : dataSets) {
            // closed ways
            removeWaysFromExistingDataSet(existingDataSet, ds.getClosedWays());
            //open ways
            removeWaysFromExistingDataSet(existingDataSet, ds.getOpenWays());
        }
        addOSMDataSet(absPath, ds);
    }

    /**
     * Removes a specific OSM XML Data Set based off of the path of the file.
     * * * 
     * @param absoluteFilePath
     */
    public void removeDataSet(String absoluteFilePath) {
        OSMDataSet ds = dataSetHash.get(absoluteFilePath);
        List<OSMWay> closedWays = ds.getClosedWays();
        List<OSMWay> openWays = ds.getOpenWays();
        List<OSMNode> standaloneNodes = ds.getStandaloneNodes();
        for (OSMWay w : closedWays) {
            try {
                Geometry geom = w.getJTSGeom();
                Envelope env = geom.getEnvelopeInternal();
                spatialIndex.remove(env, w);
            } catch (Exception e) {
                Log.e("NO_GEOM", "Cannot remove a closed way with no JTS geom.");
            }
        }
        for (OSMWay w : openWays) {
            try {
                Geometry geom = w.getJTSGeom();
                Envelope env = geom.getEnvelopeInternal();
                spatialIndex.remove(env, w);
            } catch (Exception e) {
                Log.e("NO_GEOM", "Cannot remove an open way with no JTS geom.");
            }
        }
        for (OSMNode n : standaloneNodes) {
            try {
                Geometry geom = n.getJTSGeom();
                Envelope env = geom.getEnvelopeInternal();
                spatialIndex.remove(env, n);
            } catch (Exception e) {
                Log.e("NO_GEOM", "Cannot remove a standalone node with no JTS geom.");
            }
        }
    }
    
    private void removeWaysFromExistingDataSet(OSMDataSet existingDataSet, List<OSMWay> ways) {
        for (OSMWay w : ways) {
            OSMWay oldWay = existingDataSet.getWay(w.getId());
            if (oldWay != null) {
                Geometry geom = oldWay.getJTSGeom();
                if (geom != null) {
                    Envelope env = geom.getEnvelopeInternal();
                    spatialIndex.remove(env, oldWay);
                }
            }
        }
    }
    
    public Envelope createTapEnvelope(ILatLng latLng, float zoom) {
        return createTapEnvelope(latLng.getLatitude(), latLng.getLongitude(), zoom);
    }

    public Envelope createTapEnvelope(double lat, double lng, float zoom) {
        Coordinate coord = new Coordinate(lng, lat);
        return createTapEnvelope(coord, lat, lng, zoom);
    }

    public List<OSMElement> queryFromEnvelope(Envelope envelope) {
        List<OSMElement> results = spatialIndex.query(envelope);
        return results;
    }
    
    public OSMElement queryFromTap(ILatLng latLng, float zoom) {
        double lat = latLng.getLatitude();
        double lng = latLng.getLongitude();
        Coordinate coord = new Coordinate(lng, lat);
        Envelope envelope = createTapEnvelope(coord, lat, lng, zoom);

        List results = spatialIndex.query(envelope);

        int len = results.size();
        if (len == 0 ) {
            return null;
        }
        if (len == 1) {
            return (OSMElement) results.get(0);
        }

        Point clickPoint = geometryFactory.createPoint(coord);
        OSMElement closestElement = null;
        double closestDist = Double.POSITIVE_INFINITY; // should be replaced in first for loop iteration
        for (Object res : results) {
            OSMElement el = (OSMElement) res;
            if (closestElement == null) {
                closestElement = el;
                closestDist = el.getJTSGeom().distance(clickPoint);
                continue;
            }
            Geometry geom = el.getJTSGeom();
            double dist = geom.distance(clickPoint);

            if (dist > closestDist) {
                continue;
            }

            if (dist < closestDist) {
                closestElement = el;
                closestDist = dist;
                continue;
            }

            // If we are here, then the distances are the same,
            // so we prioritize which element is better based on their type.
            closestElement = prioritizeElementByType(closestElement, el);

        }

        Geometry closestElementGeom = closestElement.getJTSGeom();
        if (closestElementGeom != null && closestElementGeom.intersects(geometryFactory.createPoint(coord))) {
            return closestElement;
        }
        return null;
    }
    
    private Envelope createTapEnvelope(Coordinate coord, double lat, double lng, float zoom) {
        Envelope envelope = new Envelope(coord);

        // Creating a reasonably sized envelope around the tap location.
        // Tweak the TAP_PIXEL_TOLERANCE to get a better sized box for your needs.
        double degreesLngPerPixel = degreesLngPerPixel(zoom);
        double deltaX = degreesLngPerPixel * TAP_PIXEL_TOLERANCE;
        double deltaY = scaledLatDeltaForMercator(deltaX, lat);
        envelope.expandBy(deltaX, deltaY);
        return envelope;
    }

    /**
     * Prioritizes points over lines over polygons.
     * @param el1
     * @param el2
     * @return the priority OSMElement type
     */
    private OSMElement prioritizeElementByType(OSMElement el1, OSMElement el2) {
        if (el1 instanceof OSMNode) {
            return el1;
        }
        if (el2 instanceof OSMNode) {
            return el2;
        }
        // It's gotta be a Way at this point...
        if ( ! ((OSMWay)el1).isClosed() ) {
            return el1;
        }
        return el2;
    }

    private void addOSMClosedWays(OSMDataSet ds) {
        List<OSMWay> closedWays = ds.getClosedWays();
        for (OSMWay w : closedWays) {
            if (!w.isModified() && OSMWay.containsModifiedWay(w.getId())) {
                continue;    
            }
            // Don't render or index ways that do not have all of their referenced nodes.
            if (w.incomplete()) {
                continue;
            }
            List<OSMNode> nodes = w.getNodes();
            Coordinate[] coords = coordArrayFromNodeList(nodes);
            Polygon poly = geometryFactory.createPolygon(coords);
            w.setJTSGeom(poly);
            Envelope envelope = poly.getEnvelopeInternal();
            spatialIndex.insert(envelope, w);
        }
    }

    private void addOSMOpenWays(OSMDataSet ds) {
        List<OSMWay> openWays = ds.getOpenWays();
        for (OSMWay w : openWays) {
            if (!w.isModified() && OSMWay.containsModifiedWay(w.getId())) {
                continue;
            }
            // Don't render or index ways that do not have all of their referenced nodes.
            if (w.incomplete()) {
                continue;
            }
            List<OSMNode> nodes = w.getNodes();
            Coordinate[] coords = coordArrayFromNodeList(nodes);
            LineString line = geometryFactory.createLineString(coords);
            w.setJTSGeom(line);
            Envelope envelope = line.getEnvelopeInternal();
            spatialIndex.insert(envelope, w);
        }
    }

    private Coordinate[] coordArrayFromNodeList(List<OSMNode> nodes) {
        Coordinate[] coords = new Coordinate[nodes.size()];
        int i = 0;
        for (OSMNode node : nodes) {
            double lat = node.getLat();
            double lng = node.getLng();
            Coordinate coord = new Coordinate(lng, lat);
            coords[i++] = coord;
        }
        return coords;
    }

    private void addOSMStandaloneNodes(OSMDataSet ds) {
        List<OSMNode> standaloneNodes = ds.getStandaloneNodes();
        for (OSMNode n : standaloneNodes) {
            double lat = n.getLat();
            double lng = n.getLng();
            Coordinate coord = new Coordinate(lng, lat);
            Point point = geometryFactory.createPoint(coord);
            n.setJTSGeom(point);
            Envelope envelope = point.getEnvelopeInternal();
            spatialIndex.insert(envelope, n);
        }
    }

    /**
     * When the user adds an OSMNode POI to the map, we want to add that new
     * node to JTSModel. This is done in OSMMap.
     *
     * @param n - the OSMNode
     */
    public void addOSMStandaloneNode(OSMNode n) {
        double lat = n.getLat();
        double lng = n.getLng();
        Coordinate coord = new Coordinate(lng, lat);
        Point point = geometryFactory.createPoint(coord);
        n.setJTSGeom(point);
        Envelope envelope = point.getEnvelopeInternal();
        spatialIndex.insert(envelope, n);
    }

    /**
     * Removes the OSMElement from the Spatial Index
     * if the element there.
     *
     * @param el - any OSMElement
     */
    public void removeOSMElement(OSMElement el) {
        Geometry geom = el.getJTSGeom();
        if (geom != null) {
            Envelope env = geom.getEnvelopeInternal();
            spatialIndex.remove(env, el);
        }
    }

    /**
     * This is how degrees wide a given pixel is for a given zoom.
     *
     * @param zoom
     * @return
     */
    private static double degreesLngPerPixel(float zoom) {
        double degreesPerTile = 360 / Math.pow(2, zoom);
        return degreesPerTile / 256;
    }

    /**
     * This is how many degrees high a given Lat Delta is for a given
     * zoom in Spherical Mercator.
     *
     * http://en.wikipedia.org/wiki/Mercator_projection#Scale_factor
     *
     * @param deltaDeg, lng
     * @return
     */
    private static double scaledLatDeltaForMercator(double deltaDeg, double lat) {
        double scale =  1 / Math.cos(Math.toRadians(lat));
        return deltaDeg / scale;
    }

}