/*******************************************************************************
 * Copyright (c) 2015 MITRE and VoyagerSearch
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Apache License, Version 2.0 which
 * accompanies this distribution and is available at
 *    http://www.apache.org/licenses/LICENSE-2.0.txt
 ******************************************************************************/

package org.locationtech.spatial4j.distance;

import com.carrotsearch.randomizedtesting.RandomizedTest;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Circle;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.SpatialRelation;
import org.locationtech.spatial4j.shape.impl.PointImpl;
import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.locationtech.spatial4j.distance.DistanceUtils.DEG_TO_KM;
import static org.locationtech.spatial4j.distance.DistanceUtils.KM_TO_DEG;

public class TestDistances extends RandomizedTest {

  //NOTE!  These are sometimes modified by tests.
  private SpatialContext ctx;
  private double EPS;

  @Before
  public void beforeTest() {
    ctx = SpatialContext.GEO;
    EPS = 10e-4;//delta when doing double assertions. Geo eps is not that small.
  }

  private DistanceCalculator dc() {
    return ctx.getDistCalc();
  }

  @Test
  public void testSomeDistances() {
    //See to verify: from http://www.movable-type.co.uk/scripts/latlong.html
    Point ctr = pLL(0,100);
    assertEquals(11100, dc().distance(ctr, pLL(10, 0)) * DEG_TO_KM, 3);
    double deg = dc().distance(ctr, pLL(10, -160));
    assertEquals(11100, deg * DEG_TO_KM, 3);

    assertEquals(314.40338, dc().distance(pLL(1, 2), pLL(3, 4)) * DEG_TO_KM, EPS);
  }

  @Test
  public void testDistancesAgainstVincenty() {
    DistanceCalculator vincenty = new GeodesicSphereDistCalc.Vincenty();
    DistanceCalculator haversine = new GeodesicSphereDistCalc.Haversine();
    DistanceCalculator lawOfCos = new GeodesicSphereDistCalc.LawOfCosines();

    final int TRIES = 100000 * (int)multiplier();
    for (int i = 0; i < TRIES; i++) {
      Point p1 = randomGeoPoint();
      Point p2 = randomGeoPointFrom(p1);
      double distV = vincenty.distance(p1, p2);

      //Haversine: accurate to a centimeter if on same side of globe,
      // otherwise possibly 1m apart (antipodal)
      double havV = haversine.distance(p1, p2);
      assertEquals(distV, havV, (distV <= 90) ? DistanceUtils.KM_TO_DEG * 0.00001 : DistanceUtils.KM_TO_DEG * 0.001);
      //  Fractionally compared to truth, also favorably accurate.
      if (distV != 0 && distV > 0.0000001)
        assertEquals(1.0, havV/distV, 0.001);//0.1%

      //LawOfCosines: accurate to within 1 meter (or better?)
      double locV = lawOfCos.distance(p1, p2);
      assertEquals(distV, locV, DistanceUtils.KM_TO_DEG * 0.001);
    }
  }

  private Point randomGeoPoint() {
    //not uniformly distributed but that's ok
    return ctx.makePoint(randomDouble()*360 + -180, randomDouble()*180 + -90);
  }

  private Point randomGeoPointFrom(Point p1) {
    int which = randomInt(10);//inclusive
    double distDEG;
    if (which <= 2) {
      distDEG = 180 - randomDouble() * 0.001 / Math.pow(10, which);
    } else if (which >= 8) {
      distDEG = randomDouble() * 0.001 / Math.pow(10, 10-which);
    } else {
      distDEG = randomDouble()*180;
    }
    double bearingDEG = randomDouble() * 360;
    Point p2RAD = DistanceUtils.pointOnBearingRAD(DistanceUtils.toRadians(p1.getY()), DistanceUtils.toRadians(p1.getX()),
            DistanceUtils.toRadians(distDEG), DistanceUtils.toRadians(bearingDEG), ctx, null);
    p2RAD.reset(DistanceUtils.toDegrees(p2RAD.getX()), DistanceUtils.toDegrees(p2RAD.getY()));
    return p2RAD;//now it's in degrees
  }


  @Test /** See #81 */
  public void testHaversineNaN() {
    assertEquals(180, new GeodesicSphereDistCalc.Haversine().distance(
                    ctx.makePoint(-81.05206968336057, 71.82629271026536),
                    98.9479297952497, -71.82629264390964),
            0.00001);
  }

  @Test
  public void testCalcBoxByDistFromPt() {
    //first test regression
    {
      double d = 6894.1 * KM_TO_DEG;
      Point pCtr = pLL(-20, 84);
      Point pTgt = pLL(-42, 15);
      assertTrue(dc().distance(pCtr, pTgt) < d);
      //since the pairwise distance is less than d, a bounding box from ctr with d should contain pTgt.
      Rectangle r = dc().calcBoxByDistFromPt(pCtr, d, ctx, null);
      assertEquals(SpatialRelation.CONTAINS,r.relate(pTgt));
      checkBBox(pCtr,d);
    }

    assertEquals("0 dist, horiz line",
        -45,dc().calcBoxByDistFromPt_yHorizAxisDEG(ctx.makePoint(-180, -45), 0, ctx),0);

    double MAXDIST = (double) 180 * DEG_TO_KM;
    checkBBox(ctx.makePoint(0,0), MAXDIST);
    checkBBox(ctx.makePoint(0,0), MAXDIST *0.999999);
    checkBBox(ctx.makePoint(0,0),0);
    checkBBox(ctx.makePoint(0,0),0.000001);
    checkBBox(ctx.makePoint(0,90),0.000001);
    checkBBox(ctx.makePoint(-32.7,-5.42),9829);
    checkBBox(ctx.makePoint(0,90-20), (double) 20 * DEG_TO_KM);
    {
      double d = 0.010;//10m
      checkBBox(ctx.makePoint(0,90- (d + 0.001) * KM_TO_DEG),d);
    }

    for (int T = 0; T < 100; T++) {
      double lat = -90 + randomDouble()*180;
      double lon = -180 + randomDouble()*360;
      Point ctr = ctx.makePoint(lon, lat);
      double dist = MAXDIST*randomDouble();
      checkBBox(ctr, dist);
    }

  }

  private void checkBBox(Point ctr, double distKm) {
    String msg = "ctr: "+ctr+" distKm: "+distKm;
    double dist = distKm * KM_TO_DEG;

    Rectangle r = dc().calcBoxByDistFromPt(ctr, dist, ctx, null);
    double horizAxisLat = dc().calcBoxByDistFromPt_yHorizAxisDEG(ctr, dist, ctx);
    if (!Double.isNaN(horizAxisLat))
      assertTrue(r.relateYRange(horizAxisLat, horizAxisLat).intersects());

    //horizontal
    if (r.getWidth() >= 180) {
      double deg = dc().distance(ctr, r.getMinX(), r.getMaxY() == 90 ? 90 : -90);
      double calcDistKm = deg * DEG_TO_KM;
      assertTrue(msg, calcDistKm <= distKm + EPS);
      //horizAxisLat is meaningless in this context
    } else {
      Point tPt = findClosestPointOnVertToPoint(r.getMinX(), r.getMinY(), r.getMaxY(), ctr);
      double calcDistKm = dc().distance(ctr, tPt) * DEG_TO_KM;
      assertEquals(msg, distKm, calcDistKm, EPS);
      assertEquals(msg, tPt.getY(), horizAxisLat, EPS);
    }

    //vertical
    double topDistKm = dc().distance(ctr, ctr.getX(), r.getMaxY()) * DEG_TO_KM;
    if (r.getMaxY() == 90)
      assertTrue(msg, topDistKm <= distKm + EPS);
    else
      assertEquals(msg, distKm, topDistKm, EPS);
    double botDistKm = dc().distance(ctr, ctr.getX(), r.getMinY()) * DEG_TO_KM;
    if (r.getMinY() == -90)
      assertTrue(msg, botDistKm <= distKm + EPS);
    else
      assertEquals(msg, distKm, botDistKm, EPS);
  }

  private Point findClosestPointOnVertToPoint(double lon, double lowLat, double highLat, Point ctr) {
    //A binary search algorithm to find the point along the vertical lon between lowLat & highLat that is closest
    // to ctr, and returns the distance.
    double midLat = (highLat - lowLat)/2 + lowLat;
    double midLatDist = ctx.getDistCalc().distance(ctr,lon,midLat);
    for(int L = 0; L < 100 && (highLat - lowLat > 0.001|| L < 20); L++) {
      boolean bottom = (midLat - lowLat > highLat - midLat);
      double newMid = bottom ? (midLat - lowLat)/2 + lowLat : (highLat - midLat)/2 + midLat;
      double newMidDist = ctx.getDistCalc().distance(ctr,lon,newMid);
      if (newMidDist < midLatDist) {
        if (bottom) {
          highLat = midLat;
        } else {
          lowLat = midLat;
        }
        midLat = newMid;
        midLatDist = newMidDist;
      } else {
        if (bottom) {
          lowLat = newMid;
        } else {
          highLat = newMid;
        }
      }
    }
    return ctx.makePoint(lon,midLat);
  }

  @Test
  public void testDistCalcPointOnBearing_cartesian() {
    ctx = new SpatialContext(false);
    EPS = 10e-6;//tighter epsilon (aka delta)
    for(int i = 0; i < 1000; i++) {
      testDistCalcPointOnBearing(randomInt(100));
    }
  }

  @Test
  public void testDistCalcPointOnBearing_geo() {
    //The haversine formula has a higher error if the points are near antipodal. We adjust EPS tolerance for this case.
    //TODO Eventually we should add the Vincenty formula for improved accuracy, or try some other cleverness.

    //test known high delta
//    {
//      Point c = ctx.makePoint(-103,-79);
//      double angRAD = Math.toRadians(236);
//      double dist = 20025;
//      Point p2 = dc().pointOnBearingRAD(c, dist, angRAD, ctx);
//      //Pt(x=76.61200011750923,y=79.04946929870962)
//      double calcDist = dc().distance(c, p2);
//      assertEqualsRatio(dist, calcDist);
//    }
    double maxDistKm = (double) 180 * DEG_TO_KM;
    for(int i = 0; i < 1000; i++) {
      int distKm = randomInt((int) maxDistKm);
      EPS = (distKm < maxDistKm*0.75 ? 10e-6 : 10e-3);
      testDistCalcPointOnBearing(distKm);
    }
  }

  private void testDistCalcPointOnBearing(double distKm) {
    for(int angDEG = 0; angDEG < 360; angDEG += randomIntBetween(1,20)) {
      Point c = ctx.makePoint(
              DistanceUtils.normLonDEG(randomInt(359)),
              randomIntBetween(-90,90));

      //0 distance means same point
      Point p2 = dc().pointOnBearing(c, 0, angDEG, ctx, null);
      assertEquals(c,p2);

      p2 = dc().pointOnBearing(c, distKm * KM_TO_DEG, angDEG, ctx, null);
      double calcDistKm = dc().distance(c, p2) * DEG_TO_KM;
      assertEqualsRatio(distKm, calcDistKm);
    }
  }

  private void assertEqualsRatio(double expected, double actual) {
    double delta = Math.abs(actual - expected);
    double base = Math.min(actual, expected);
    double deltaRatio = base==0 ? delta : Math.min(delta,delta / base);
    assertEquals(0,deltaRatio, EPS);
  }

  @Test
  public void testNormLat() {
    double[][] lats = new double[][] {
        {1.23,1.23},//1.23 might become 1.2299999 after some math and we want to ensure that doesn't happen
        {-90,-90},{90,90},{0,0}, {-100,-80},
        {-90-180,90},{-90-360,-90},{90+180,-90},{90+360,90},
        {-12+180,12}};
    for (double[] pair : lats) {
      assertEquals("input "+pair[0], pair[1], DistanceUtils.normLatDEG(pair[0]), 0);
    }

    for(int i = -1000; i < 1000; i += randomInt(9)*10) {
      double d = DistanceUtils.normLatDEG(i);
      assertTrue(i + " " + d, d >= -90 && d <= 90);
    }
  }

  @Test
  public void testNormLon() {
    double[][] lons = new double[][] {
        {1.23,1.23},//1.23 might become 1.2299999 after some math and we want to ensure that doesn't happen
        {-180,-180},{180,+180},{0,0}, {-190,170},{181,-179},
        {-180-360,-180},{-180-720,-180},
        {180+360,+180},{180+720,+180}};
    for (double[] pair : lons) {
      assertEquals("input " + pair[0], pair[1], DistanceUtils.normLonDEG(pair[0]), 0);
    }

    for(int i = -1000; i < 1000; i += randomInt(9)*10) {
      double d = DistanceUtils.normLonDEG(i);
      assertTrue(i + " " + d, d >= -180 && d <= 180);
    }
  }

  @Test
  public void assertDistanceConversion() {
    assertDistanceConversion(0);
    assertDistanceConversion(500);
    assertDistanceConversion(DistanceUtils.EARTH_MEAN_RADIUS_KM);
  }

  private void assertDistanceConversion(double dist) {
    double radius = DistanceUtils.EARTH_MEAN_RADIUS_KM;
    //test back & forth conversion for both
    double distRAD = DistanceUtils.dist2Radians(dist, radius);
    assertEquals(dist, DistanceUtils.radians2Dist(distRAD, radius), EPS);
    double distDEG = DistanceUtils.dist2Degrees(dist, radius);
    assertEquals(dist, DistanceUtils.degrees2Dist(distDEG, radius), EPS);
    //test across rad & deg
    assertEquals(distDEG,DistanceUtils.toDegrees(distRAD),EPS);
    //test point on bearing
    assertEquals(
        DistanceUtils.pointOnBearingRAD(0, 0, DistanceUtils.dist2Radians(dist, radius), DistanceUtils.DEG_90_AS_RADS, ctx, new PointImpl(0, 0, ctx)).getX(),
        distRAD, 10e-5);
  }

  private Point pLL(double lat, double lon) {
    return ctx.makePoint(lon,lat);
  }

  @Test
  public void testArea() {
    double radius = DistanceUtils.EARTH_MEAN_RADIUS_KM * KM_TO_DEG;
    //surface of a sphere is 4 * pi * r^2
    final double earthArea = 4 * Math.PI * radius * radius;

    Circle c = ctx.makeCircle(randomIntBetween(-180,180), randomIntBetween(-90,90),
            180);//180 means whole earth
    assertEquals(earthArea, c.getArea(ctx), 1.0);
    assertEquals(earthArea, ctx.getWorldBounds().getArea(ctx), 1.0);

    //now check half earth
    Circle cHalf = ctx.makeCircle(c.getCenter(), 90);
    assertEquals(earthArea/2, cHalf.getArea(ctx), 1.0);

    //circle with same radius at +20 lat with one at -20 lat should have same area as well as bbox with same area
    Circle c2 = ctx.makeCircle(c.getCenter(), 30);
    Circle c3 = ctx.makeCircle(c.getCenter().getX(), 20, 30);
    assertEquals(c2.getArea(ctx), c3.getArea(ctx), 0.01);
    Circle c3Opposite = ctx.makeCircle(c.getCenter().getX(), -20, 30);
    assertEquals(c3.getArea(ctx), c3Opposite.getArea(ctx), 0.01);
    assertEquals(c3.getBoundingBox().getArea(ctx), c3Opposite.getBoundingBox().getArea(ctx), 0.01);

    //small shapes near the equator should have similar areas to euclidean rectangle
    Rectangle smallRect = ctx.makeRectangle(0, 1, 0, 1);
    assertEquals(1.0, smallRect.getArea(null), 0.0);
    double smallDelta = smallRect.getArea(null) - smallRect.getArea(ctx);
    assertTrue(smallDelta > 0 && smallDelta < 0.0001);

    Circle smallCircle = ctx.makeCircle(0,0,1);
    smallDelta = smallCircle.getArea(null) - smallCircle.getArea(ctx);
    assertTrue(smallDelta > 0 && smallDelta < 0.0001);

    //bigger, but still fairly similar
    //c2 = ctx.makeCircle(c.getCenter(), 30);
    double areaRatio = c2.getArea(null) / c2.getArea(ctx);
    assertTrue(areaRatio > 1 && areaRatio < 1.1);
  }

}
