/*
 * RiskScape™ Copyright New Zealand Institute for Earth Science Limited
 * (Earth Sciences New Zealand) is distributed for research purposes only
 * under the terms of AGPLv3.
 *
 * RiskScape™ Copyright 2026 New Zealand Institute for Earth Science
 * Limited (Earth Sciences New Zealand). All rights reserved. Source code
 * available under the AGPLv3.
 * 
 * This program is free software: you can redistribute it and/or modify it under
 *  the terms of the GNU Affero 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 for RESEARCH PURPOSES ONLY, in the hope that it will
 * be useful for research and education initiatives.
 * 
 * If you are not a researcher, or you are a researcher who wishes to use this
 * program on terms other than AGPLv3 (including those who wish to restrict the
 * distribution of any source code created using this program), please contact:
 * https://riskscape.org.nz
 * 
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.  You should have received a copy
 * of the GNU Affero General Public License along with this program.  If not, see
 * <http://www.gnu.org/licenses/>.
 * 
 * By way of summary only, under the AGPLv3:
 *     • Permissions of this strongest copyleft license are conditioned
 *       on making available complete source code of licensed works and
 *       modifications, which include larger works using a licensed work,
 *       under the same license.
 *     • Copyright and license notices must be preserved.
 *     • Contributors provide an express grant of patent rights.
 *     • When a modified version is used to provide a service over a
 *       network, the complete source code of the modified version must be made
 *       available.
 */
package nz.org.riskscape.engine;

import static org.hamcrest.Matchers.*;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiPredicate;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;
import org.hamcrest.TypeSafeMatcher;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlay.snap.GeometrySnapper;
import org.locationtech.jts.precision.GeometryPrecisionReducer;

public class GeoHelper {

  private static final WKTReader WKT_READER = new WKTReader(new GeometryFactory());

  /**
   * Tolerance to use for nearest mm (millimeter) precision when working with a projection that measures
   * in meters.
   */
  public static final double METER_TOLERANCE_NEAREST_MM = 0.001D;

  /**
   * Tolerance to use for nearest cm (centimeter) precision when working with a projection that measures
   * in meters.
   */
  public static final double METER_TOLERANCE_NEAREST_CM = 0.01D;

  /**
   * Tolerance to use for nearest mm (millimeter) precision when working with a projection that measures
   * in degrees.
   *
   * One degree contains 60 nautical miles which are 1852 in length.
   */
  public static final double DEGREE_TOLERANCE_NEAREST_MM = METER_TOLERANCE_NEAREST_MM / (60 * 1852);

  public static final double DEGREE_TOLERANCE_NEAREST_CM = METER_TOLERANCE_NEAREST_CM / (60 * 1852);

  public static Coordinate[] roundCoordinates(Coordinate[] coordinates, int roundTo) {
    for (int i = 0; i < coordinates.length; i++) {
      Coordinate coordinate = coordinates[i];
      coordinate.x = round(coordinate.x, roundTo);
      coordinate.y = round(coordinate.y, roundTo);
    }
    return coordinates;
  }

  public static double round(double x, int roundTo) {
    return new BigDecimal(x).setScale(roundTo, RoundingMode.HALF_EVEN).doubleValue();
  }

  public static List<Coordinate> roundCoordinates(Geometry geometry, int roundTo) {
    return Arrays.asList(roundCoordinates(geometry.norm().getCoordinates(), roundTo));
  }

  /**
   * @return a matcher that will match a geometry WKT is within some tolerance of the actual WKT.
   */
  public static Matcher<String> wktGeometryMatch(String wkt, double tolerance) {
    Geometry expected = parseWkt(wkt);
    return new TypeSafeDiagnosingMatcher<String>(String.class) {
      @Override
      public void describeTo(Description description) {
        description.appendValue(wkt).appendText(" within ").appendValue(tolerance);
      }

      @Override
      protected boolean matchesSafely(String item, Description mismatchDescription) {

        Geometry actual = parseWkt(item);
        return geometryMatch(expected, tolerance).matches(actual);
      }
    };
  }

  /**
  * @return a matcher that does a strict equality check against the expected geometry, but within a given tolerance.
  * Both geometries are normalized before being given to {@link Geometry#equalsExact(Geometry, double)}
  */
  public static  Matcher<Geometry> geometryMatch(Geometry expected, double tolerance) {
    return geometryMatch(expected, (l, r) -> l.equalsExact(r, tolerance), anything());
  }

  /**
   * @return a matcher that does a topological equality check against the expected geometry.  Both geometries
   * are normalized before being given to {@link Geometry#equalsTopo(Geometry)}
   */
  public static  Matcher<Geometry> geometryTopoMatch(Geometry expected) {
    return geometryMatch(expected, (l, r) -> l.equalsTopo(r), anything());
  }

  /**
   * @return a matcher that does a topological equality check against the expected geometry, with precision reduction.
   * Both geometries are normalized before being given to {@link Geometry#equalsTopo(Geometry)}
   */
  public static Matcher<Geometry> geometryTopoMatch(Geometry expected, PrecisionModel convertToPrecision) {
    GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(convertToPrecision);
    return geometryMatch(expected, (l, r) -> reducer.reduce(l).equalsTopo(reducer.reduce(r)), anything());
  }

  /**
   * Same as {@link #geometryTopoMatch(org.locationtech.jts.geom.Geometry) } but with an additional check
   * that the geometry is of the expected class.
   *
   * This is useful where expected could topologically match geometry of different types. This would most
   * likely be a polygon would match a multipolygon with a single polygon member or vice versa.
   */
  public static  Matcher<Geometry> geometryTopoMatch(Geometry expected, Class expectedGeomClass) {
    return geometryMatch(expected, (l, r) -> l.equalsTopo(r), instanceOf(expectedGeomClass));
  }

  /**
   * Constructs a Matcher for comparing two Geometries for equality, as defined by the given predicate.  The matcher
   * takes care of normalizing both geometries before passing them to the predicate - this is usually required for
   * `equals` to compare them correctly.
   *
   * Most people will want to use {@link #geometryMatch(Geometry, double)} and {@link #geometryTopoMatch(Geometry)}
   * instead of this.
   *
   * TODO a version that combined the tolerance and topology matchers could be built using {@link GeometrySnapper}
   * and then performing equalsTopo
   */
  public static Matcher<Geometry> geometryMatch(Geometry expected, BiPredicate<Geometry, Geometry> predicate,
      Matcher<Object> classMatcher) {
    Geometry expectedNorm = expected.norm();

    return new TypeSafeMatcher<Geometry>(Geometry.class) {

      @Override
      public void describeTo(Description description) {
        description.appendValue(expected);
        description.appendText(" of class: ").appendDescriptionOf(classMatcher);
      }

      @Override
      protected void describeMismatchSafely(Geometry item, Description mismatchDescription) {
        mismatchDescription.appendValue(item);
        mismatchDescription.appendText(" of class: ").appendValue(item.getClass());
      }

      @Override
      protected boolean matchesSafely(Geometry geometry) {
        Geometry actualNorm = geometry.norm();

        // NB top is
        return classMatcher.matches(geometry) && predicate.test(actualNorm, expectedNorm);
      }
    };
  }

  public static void normalizeAndRound(Geometry geometry, int roundTo) {
    normalizeAndRound2(geometry, roundTo);
    geometry.normalize();
  }

  private static void normalizeAndRound2(Geometry geometry, int roundTo) {
    if (geometry instanceof Polygon) {
      Polygon polygon = (Polygon) geometry;
      normalizeAndRound(polygon.getExteriorRing(), 2);
      for (int i = 0; i < polygon.getNumInteriorRing(); i++) {
        normalizeAndRound(polygon.getInteriorRingN(i), roundTo);
      }
    } else if (geometry instanceof LineString) {
      CoordinateSequence cs = ((LineString) geometry).getCoordinateSequence();
      for (int i = 0; i < cs.size(); i++) {
        cs.setOrdinate(0, i, round(cs.getOrdinate(0, i), roundTo));
        cs.setOrdinate(1, i, round(cs.getOrdinate(1, i), roundTo));
      }
    } else {
      throw new UnsupportedOperationException(geometry.getGeometryType() + " not supported");
    }

  }

  private static Geometry parseWkt(String wkt) {
    try {
      return WKT_READER.read(wkt);
    } catch (ParseException e) {
      throw new RuntimeException(e);
    }
  }

}
