/*
 * Copyright 2012 osmdroid authors: Viesturs Zarins, Martin Pearman
 * Copyright 2012 Hannes Janetzek
 * Copyright 2016-2018 devemux86
 * Copyright 2016 Bezzu
 * Copyright 2016 Pedinel
 * Copyright 2017 Andrey Novikov
 * Copyright 2018 Gustl22
 *
 * This file is part of the OpenScienceMap project (http://www.opensciencemap.org).
 *
 * This program is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later version.
 *
 * This program 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License along with
 * this program. If not, see <http://www.gnu.org/licenses/>.
 */
package org.oscim.layers;

import org.oscim.backend.CanvasAdapter;
import org.oscim.backend.canvas.Paint.Cap;
import org.oscim.core.GeoPoint;
import org.oscim.core.GeometryBuffer;
import org.oscim.core.MapPosition;
import org.oscim.core.MercatorProjection;
import org.oscim.core.Point;
import org.oscim.core.Tile;
import org.oscim.event.Gesture;
import org.oscim.event.GestureListener;
import org.oscim.event.MotionEvent;
import org.oscim.map.Map;
import org.oscim.renderer.BucketRenderer;
import org.oscim.renderer.GLViewport;
import org.oscim.renderer.MapRenderer;
import org.oscim.renderer.bucket.LineBucket;
import org.oscim.renderer.bucket.RenderBuckets;
import org.oscim.theme.styles.LineStyle;
import org.oscim.utils.FastMath;
import org.oscim.utils.GeoPointUtils;
import org.oscim.utils.async.SimpleWorker;
import org.oscim.utils.geom.LineClipper;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * This class draws a path line in given color or texture.
 */
public class PathLayer extends Layer implements GestureListener {

    private static final int STROKE_MIN_ZOOM = 12;

    /**
     * Stores points, converted to the map projection.
     */
    protected final ArrayList<GeoPoint> mPoints;
    protected boolean mUpdatePoints;

    private final Point mPoint1 = new Point();
    private final Point mPoint2 = new Point();

    /**
     * Line style
     */
    LineStyle mLineStyle;

    final Worker mWorker;

    public PathLayer(Map map, LineStyle style) {
        super(map);
        mLineStyle = style;

        mPoints = new ArrayList<>();
        mRenderer = new PathRenderer();
        mWorker = new Worker(map);
    }

    public PathLayer(Map map, int lineColor, float lineWidth) {
        this(map, new LineStyle(lineColor, lineWidth, Cap.BUTT));
    }

    public PathLayer(Map map, int lineColor) {
        this(map, lineColor, 2);
    }

    public void setStyle(LineStyle style) {
        mLineStyle = style;
    }

    public void clearPath() {
        if (mPoints.isEmpty())
            return;

        synchronized (mPoints) {
            mPoints.clear();
        }
        updatePoints();
    }

    public void setPoints(Collection<? extends GeoPoint> pts) {
        synchronized (mPoints) {
            mPoints.clear();
            mPoints.addAll(pts);
        }
        updatePoints();
    }

    public void addPoint(GeoPoint pt) {
        synchronized (mPoints) {
            mPoints.add(pt);
        }
        updatePoints();
    }

    public void addPoint(int latitudeE6, int longitudeE6) {
        synchronized (mPoints) {
            mPoints.add(new GeoPoint(latitudeE6, longitudeE6));
        }
        updatePoints();
    }

    public void addPoints(Collection<? extends GeoPoint> pts) {
        synchronized (mPoints) {
            mPoints.addAll(pts);
        }
        updatePoints();
    }

    private void updatePoints() {
        mWorker.submit(10);
        mUpdatePoints = true;
    }

    public List<GeoPoint> getPoints() {
        return mPoints;
    }

    /**
     * FIXME To be removed
     *
     * @deprecated
     */
    public void setGeom(GeometryBuffer geom) {
        mGeom = geom;
        mWorker.submit(10);
    }

    GeometryBuffer mGeom;

    /**
     * Draw a great circle. Calculate a point for every 100km along the path.
     *
     * @param startPoint start point of the great circle
     * @param endPoint   end point of the great circle
     */
    public void addGreatCircle(GeoPoint startPoint, GeoPoint endPoint) {
        synchronized (mPoints) {

            /* get the great circle path length in meters */
            double length = startPoint.sphericalDistance(endPoint);

            /* add one point for every 100kms of the great circle path */
            int numberOfPoints = (int) (length / 100000);
            if (numberOfPoints == 0)
                return;

            addGreatCircle(startPoint, endPoint, numberOfPoints);
        }
    }

    /**
     * Draw a great circle.
     *
     * @param startPoint     start point of the great circle
     * @param endPoint       end point of the great circle
     * @param numberOfPoints number of points to calculate along the path
     */
    public void addGreatCircle(GeoPoint startPoint, GeoPoint endPoint,
                               final int numberOfPoints) {
        // adapted from page
        // http://compastic.blogspot.co.uk/2011/07/how-to-draw-great-circle-on-map-in.html
        // which was adapted from page http://maps.forum.nu/gm_flight_path.html

        // convert to radians
        double lat1 = startPoint.getLatitude() * Math.PI / 180;
        double lon1 = startPoint.getLongitude() * Math.PI / 180;
        double lat2 = endPoint.getLatitude() * Math.PI / 180;
        double lon2 = endPoint.getLongitude() * Math.PI / 180;

        double d = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin((lat1 - lat2) / 2), 2)
                + Math.cos(lat1) * Math.cos(lat2)
                * Math.pow(Math.sin((lon1 - lon2) / 2), 2)));
        double bearing = Math.atan2(
                Math.sin(lon1 - lon2) * Math.cos(lat2),
                Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1)
                        * Math.cos(lat2)
                        * Math.cos(lon1 - lon2))
                / -(Math.PI / 180);
        bearing = bearing < 0 ? 360 + bearing : bearing;

        for (int i = 0, j = numberOfPoints + 1; i < j; i++) {
            double f = 1.0 / numberOfPoints * i;
            double A = Math.sin((1 - f) * d) / Math.sin(d);
            double B = Math.sin(f * d) / Math.sin(d);
            double x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2)
                    * Math.cos(lon2);
            double y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2)
                    * Math.sin(lon2);
            double z = A * Math.sin(lat1) + B * Math.sin(lat2);

            double latN = Math.atan2(z, Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)));
            double lonN = Math.atan2(y, x);
            addPoint((int) (latN / (Math.PI / 180) * 1E6), (int) (lonN / (Math.PI / 180) * 1E6));
        }
    }

    /***
     * everything below runs on GL- and Worker-Thread
     ***/
    final class PathRenderer extends BucketRenderer {

        private int mCurX = -1;
        private int mCurY = -1;
        private int mCurZ = -1;

        @Override
        public synchronized void update(GLViewport v) {
            int tz = 1 << v.pos.zoomLevel;
            int tx = (int) (v.pos.x * tz);
            int ty = (int) (v.pos.y * tz);

            /* update layers when map moved by at least one tile */
            if ((tx != mCurX || ty != mCurY || tz != mCurZ)) {
                mWorker.submit(100);
                mCurX = tx;
                mCurY = ty;
                mCurZ = tz;
            }

            Task t = mWorker.poll();
            if (t == null)
                return;

            /* keep position to render relative to current state */
            mMapPosition.copy(t.position);

            /* compile new layers */
            buckets.set(t.buckets.get());
            compile();
        }
    }

    static final class Task {
        final RenderBuckets buckets = new RenderBuckets();
        final MapPosition position = new MapPosition();
    }

    final class Worker extends SimpleWorker<Task> {

        // limit coords to maximum resolution of GL.Short
        private final int MAX_CLIP = (int) (Short.MAX_VALUE / MapRenderer.COORD_SCALE);

        public Worker(Map map) {
            super(map, 0, new Task(), new Task());
            mClipper = new LineClipper(-MAX_CLIP, -MAX_CLIP, MAX_CLIP, MAX_CLIP);
            mPPoints = new float[0];
        }

        private static final int MIN_DIST = 3;

        // pre-projected points
        private double[] mPreprojected = new double[2];

        // projected points
        private float[] mPPoints;
        private final LineClipper mClipper;
        private int mNumPoints;

        @Override
        public boolean doWork(Task task) {

            int size = mNumPoints;

            if (mUpdatePoints) {
                synchronized (mPoints) {
                    mUpdatePoints = false;
                    mNumPoints = size = mPoints.size();

                    ArrayList<GeoPoint> geopoints = mPoints;
                    double[] points = mPreprojected;

                    if (size * 2 >= points.length) {
                        points = mPreprojected = new double[size * 2];
                        mPPoints = new float[size * 2];
                    }

                    for (int i = 0; i < size; i++)
                        MercatorProjection.project(geopoints.get(i), points, i);
                }

            } else if (mGeom != null) {
                GeometryBuffer geom = mGeom;
                mGeom = null;
                size = geom.index[0];

                double[] points = mPreprojected;

                if (size > points.length) {
                    points = mPreprojected = new double[size * 2];
                    mPPoints = new float[size * 2];
                }

                for (int i = 0; i < size; i += 2)
                    MercatorProjection.project(geom.points[i + 1],
                            geom.points[i], points,
                            i >> 1);
                mNumPoints = size = size >> 1;

            }
            if (size == 0) {
                if (task.buckets.get() != null) {
                    task.buckets.clear();
                    mMap.render();
                }
                return true;
            }

            LineBucket ll;

            if (mLineStyle.stipple == 0 && mLineStyle.texture == null)
                ll = task.buckets.getLineBucket(0);
            else
                ll = task.buckets.getLineTexBucket(0);

            ll.line = mLineStyle;

            if (!mLineStyle.fixed && mLineStyle.strokeIncrease > 1)
                ll.scale = (float) Math.pow(mLineStyle.strokeIncrease, Math.max(task.position.getZoom() - STROKE_MIN_ZOOM, 0));

            mMap.getMapPosition(task.position);

            int zoomlevel = task.position.zoomLevel;
            task.position.scale = 1 << zoomlevel;

            double mx = task.position.x;
            double my = task.position.y;
            double scale = Tile.SIZE * task.position.scale;

            // flip around dateline
            int flip = 0;
            int maxx = Tile.SIZE << (zoomlevel - 1);

            int x = (int) ((mPreprojected[0] - mx) * scale);
            int y = (int) ((mPreprojected[1] - my) * scale);

            if (x > maxx) {
                x -= (maxx * 2);
                flip = -1;
            } else if (x < -maxx) {
                x += (maxx * 2);
                flip = 1;
            }

            mClipper.clipStart(x, y);

            float[] projected = mPPoints;
            int i = addPoint(projected, 0, x, y);

            float prevX = x;
            float prevY = y;

            float[] segment = null;

            for (int j = 2; j < size * 2; j += 2) {
                x = (int) ((mPreprojected[j + 0] - mx) * scale);
                y = (int) ((mPreprojected[j + 1] - my) * scale);

                int flipDirection = 0;
                if (x > maxx) {
                    x -= maxx * 2;
                    flipDirection = -1;
                } else if (x < -maxx) {
                    x += maxx * 2;
                    flipDirection = 1;
                }

                if (flip != flipDirection) {
                    flip = flipDirection;
                    if (i > 2)
                        ll.addLine(projected, i, false);

                    mClipper.clipStart(x, y);
                    i = addPoint(projected, 0, x, y);
                    continue;
                }

                int clip = mClipper.clipNext(x, y);
                if (clip != LineClipper.INSIDE) {
                    if (i > 2)
                        ll.addLine(projected, i, false);

                    if (clip == LineClipper.INTERSECTION) {
                        /* add line segment */
                        segment = mClipper.getLine(segment, 0);
                        ll.addLine(segment, 4, false);
                        // the prev point is the real point not the clipped point
                        //prevX = mClipper.outX2;
                        //prevY = mClipper.outY2;
                        prevX = x;
                        prevY = y;
                    }
                    i = 0;
                    // if the end point is inside, add it
                    if (mClipper.getPrevOutcode() == LineClipper.INSIDE) {
                        projected[i++] = prevX;
                        projected[i++] = prevY;
                    }
                    continue;
                }

                float dx = x - prevX;
                float dy = y - prevY;
                if ((i == 0) || FastMath.absMaxCmp(dx, dy, MIN_DIST)) {
                    projected[i++] = prevX = x;
                    projected[i++] = prevY = y;
                }
            }
            if (i > 2)
                ll.addLine(projected, i, false);

            // trigger redraw to let renderer fetch the result.
            mMap.render();

            return true;
        }

        @Override
        public void cleanup(Task task) {
            task.buckets.clear();
        }

        private int addPoint(float[] points, int i, int x, int y) {
            points[i++] = x;
            points[i++] = y;
            return i;
        }
    }

    public synchronized boolean contains(float x, float y) {
        // Touch min 20 px at baseline mdpi (160dpi)
        double distance = Math.max(20 / 2 * CanvasAdapter.getScale(), mLineStyle.width);
        for (int i = 0; i < mPoints.size() - 1; i++) {
            if (i == 0)
                mMap.viewport().toScreenPoint(mPoints.get(i), false, mPoint1);
            else {
                mPoint1.x = mPoint2.x;
                mPoint1.y = mPoint2.y;
            }
            mMap.viewport().toScreenPoint(mPoints.get(i + 1), false, mPoint2);
            if (GeoPointUtils.distanceSegmentPoint(mPoint1.x, mPoint1.y, mPoint2.x, mPoint2.y, x, y) <= distance)
                return true;
        }
        return false;
    }

    @Override
    public boolean onGesture(Gesture g, MotionEvent e) {
        return false;
    }
}
