/*
 * 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.geo;

import static nz.org.riskscape.engine.Assert.*;
import static nz.org.riskscape.engine.GeoHelper.*;
import static nz.org.riskscape.engine.Matchers.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.util.List;
import java.util.stream.Collectors;

import org.junit.Test;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Polygon;

import nz.org.riskscape.engine.CrsHelper;
import nz.org.riskscape.engine.GeoHelper;
import nz.org.riskscape.engine.GeometryProblems;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.geo.ProjectGeometryOp.Projected;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.problem.Problem.Severity;

public class ProjectGeometryOpTest extends ProjectTest implements CrsHelper {

  GeometryHelper nztmHelper = GeometryHelper.nzTransverseMercatorWithOriginShift(project.getSridSet());
  GeometryHelper nzgdHelper = GeometryHelper.nzGrid(project.getSridSet());
  GeometryHelper latLongHelper = GeometryHelper.latLong(project.getSridSet());
  GeometryHelper longLatHelper = GeometryHelper.longLat(project.getSridSet());
  ProjectGeometryOp subject = new ProjectGeometryOp(project.getSridSet());

  @Test
  public void canProjectLineInNZ() {
    // we start with an NZ line, because meters are easy to understand
    LineString nzLine = nztmHelper.line(0, 0, 1000, 0);
    assertThat(nzLine.getLength(), is(1000.0D));  //sanity check

    // now we reproject it to lat long
    LineString latLongLine = (LineString)latLongHelper.reproject(nzLine);
    // sanity check that geom has recalculated length in degrees
    assertThat(latLongLine.getLength(), closeTo(0.009D, 0.001));

    // projecting to autoCRS should be close to the original length in metres
    assertThat(subject.apply(latLongLine).stream()
        .map(p -> p.getProjected().getLength())
        .collect(Collectors.summingDouble(v -> v)), closeTo(1000.0D, 1.2D));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canProjectLineWithLongitudeOver180() {
    LineString nzgdLine = nzgdHelper.line(-40, 181, -40, 181.2);

    // let's reproject to nztm and measure it
    double expectedLength = nztmHelper.reproject(nzgdLine).getLength();
    assertThat(expectedLength, closeTo(17172.65D, 0.1D));

    double projectedLength = subject.apply(nzgdLine).stream()
        .map(p -> p.getProjected().getLength())
        .collect(Collectors.summingDouble(v -> v));
    assertThat(projectedLength, closeTo(17077.47D, 0.1D));
    assertThat(projectedLength, closeTo(expectedLength, 100D));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canProjectPolygonInNZ() {
    Polygon nzShape = nztmHelper.box(0, 0, 100, 100);
    assertThat(nzShape.getArea(), is(10000.0D)); // sanity check

    Polygon latLongShape = (Polygon) latLongHelper.reproject(nzShape);
    // sanity check that geom has recalculated area in degrees
    assertThat(latLongShape.getArea(), closeTo(0.000001, 0.000001));

    // projecting to autoCRS should be close to the original area in metres
    // (there is some precision loss as the shape skews slightly to one side)
    assertThat(subject.apply(latLongShape).stream()
        .map(p -> p.getProjected().getArea())
        .collect(Collectors.summingDouble(v -> v)), closeTo(10000.0D, 10D));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canProjectLineInMetricCrs() {
    LineString nzLine = nztmHelper.line(0, 0, 0, 1000);
    assertThat(nzLine.getLength(), is(1000.0D));
    List<Geometry> projected = subject.apply(nzLine).stream()
        .map(p -> p.getProjected())
        .collect(Collectors.toList());
    // there should be no loss of precision because line was already metric
    assertThat(projected.stream()
        .map(geom -> geom.getLength())
        .collect(Collectors.summingDouble(v -> v)), is(1000.0D));
    // actually, the projected line should be identical to what we started with
    assertThat(projected.get(0), is(nzLine));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canReprojectProjectedLineBackToSourceCrs() {
    LineString nzLine = nztmHelper.line(0, 0, 0, 1000);
    LineString latLongLine = (LineString) latLongHelper.reproject(nzLine);

    // use toSourceCrs() to transform from the projected autoCRS back to lat/long
    Geometry backToSource = subject.apply(latLongLine).stream()
        .map(p -> p.toSourceCrs(p.getProjected()))
        .collect(Collectors.toList()).get(0);
    assertThat(backToSource, geometryMatch(latLongLine, DEGREE_TOLERANCE_NEAREST_MM));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canReprojectProjectedPolygonBackToSourceCrs() {
    Polygon nzShape = nztmHelper.box(0, 0, 100, 100);
    Polygon latLongShape = (Polygon) latLongHelper.reproject(nzShape);

    Geometry backToSource = subject.apply(latLongShape).stream()
        .map(p -> p.toSourceCrs(p.getProjected()))
        .collect(Collectors.toList()).get(0);
    assertThat(backToSource, geometryMatch(latLongShape, DEGREE_TOLERANCE_NEAREST_MM));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canReprojectModifiedShapeBackToSourceCrs() {
    LineString nzLine = nztmHelper.line(0, 0, 0, 1000);
    LineString latLongLine = (LineString) latLongHelper.reproject(nzLine);

    // buffer the line by 1m and reproject back to lat/long
    Geometry latLongBuffered = subject.apply(latLongLine).stream()
        .map(p -> p.toSourceCrs(p.getProjected().buffer(1)))
        .collect(Collectors.toList()).get(0);
    Geometry expected = latLongHelper.reproject(nzLine.buffer(1));
    assertThat(latLongBuffered, geometryMatch(expected, DEGREE_TOLERANCE_NEAREST_MM));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canProjectBboxOfNZ() {
    assertThat(subject.apply(latLongHelper.box(-47, 166, -34, 178)).stream()
        .map(p -> p.getProjected().getArea())
        .collect(Collectors.summingDouble(v -> v)),
        closeTo(1.459748057918147E12D, GeoHelper.METER_TOLERANCE_NEAREST_MM));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void canProjectBboxOfEasternAustralia() {
    // approximately a bbox around Queensland, New South Wales, Victoria and Tasmania
    assertThat(subject.apply(latLongHelper.box(-43, 140, -11, 153)).stream()
        .map(p -> p.getProjected().getArea())
        .collect(Collectors.summingDouble(v -> v)),
        closeTo(4.416703860485376E12D, GeoHelper.METER_TOLERANCE_NEAREST_MM));

    assertThat(sunkProblems, hasSize(0));
  }

  @Test
  public void handlesGeometryThatBecomesInvalidAndCanBeFixed() {
    Geometry invalidWhenProjected = latLongHelper.fromWkt("POLYGON ((-46.337645271124124 165.14629469705895, "
        + "-46.31974453180639 165.14880262533865, -46.318004677055015 165.1229670992294, "
        + "-46.31887531839142 165.13588463563147, -46.33677499295321 165.133372725189, "
        + "-46.337645271124124 165.14629469705895))");
    // sanity check that this geom is valid to start with
    assertTrue(invalidWhenProjected.isValid());

    // with validation off, we expect an invalid geom back and no sunk problems
    project.getSridSet().setValidationPostReproject(GeometryValidation.OFF);
    assertFalse(apply(invalidWhenProjected).isValid());
    assertThat(sunkProblems, hasSize(0));

    // with warn mode we expect the geometry to be fixed, but we should get a problem
    // telling us this happened.
    project.getSridSet().setValidationPostReproject(GeometryValidation.WARN);
    assertTrue(apply(invalidWhenProjected).isValid());
    assertThat(sunkProblems, contains(
        isProblem(Severity.WARNING, GeometryProblems.class, "fixedInvalidPostReprojection")
    ));

    sunkProblems.clear();

    // and same again but with error turned on
    project.getSridSet().setValidationPostReproject(GeometryValidation.ERROR);
    assertTrue(apply(invalidWhenProjected).isValid());
    assertThat(sunkProblems, contains(
        isProblem(Severity.WARNING, GeometryProblems.class, "fixedInvalidPostReprojection")
    ));
  }

  @Test
  public void failsWhenProjectingLargeGeom() {
    // approximately a bbox around Australia, far too big
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> subject.apply(latLongHelper.box(-43, 113, -11, 153)));
    assertThat(ex.getProblem(), hasAncestorProblem(
        isProblem(Severity.ERROR, GeometryProblems.class, "cannotReproject")
    ));
  }

  @Test
  public void canProjectAnotherNZTMGeometryAlso() {
    // use two lines so we can measure them easily as a sanity-check
    LineString nzLineA = nztmHelper.line(0, 0, 0, 100);
    LineString nzLineB = nztmHelper.line(0, 100, 0, 200);
    assertThat(nzLineA.getLength() + nzLineB.getLength(), is(200.0D));

    // NZTM is already metric, so projection should do nothing
    Projected projected = subject.apply(nzLineA).get(0);
    Geometry projectedA = projected.getProjected();
    Geometry projectedB = projected.projectAlso(nzLineB);
    assertEquals(projectedA.getSRID(), projectedB.getSRID());
    assertThat(projectedA.getLength() + projectedB.getLength(), is(200.0D));
    assertThat(projectedA, geometryMatch(nzLineA, METER_TOLERANCE_NEAREST_CM));
    assertThat(projectedB, geometryMatch(nzLineB, METER_TOLERANCE_NEAREST_CM));

    // check we can also project a line in another CRS
    projectedB = projected.projectAlso(latLongHelper.reproject(nzLineB));
    assertEquals(projectedA.getSRID(), projectedB.getSRID());
    assertThat(projectedA.getLength() + projectedB.getLength(), closeTo(200.0D, 0.1));
    assertThat(projectedB, geometryMatch(nzLineB, METER_TOLERANCE_NEAREST_CM));
  }

  @Test
  public void canProjectAnotherLatLongGeometryAlso() {
    // this time start off with lat/long lines
    LineString nzLineA = nztmHelper.line(0, 0, 0, 100);
    LineString latLongLineA = (LineString) latLongHelper.reproject(nzLineA);
    LineString nzLineB = nztmHelper.line(0, 100, 0, 200);
    LineString latLongLineB = (LineString) latLongHelper.reproject(nzLineB);
    // sanity-check lines are in degrees, not metres
    assertThat(latLongLineA.getLength() + latLongLineB.getLength(), closeTo(0.002D, 0.001));

    Projected projected = subject.apply(latLongLineA).get(0);
    Geometry projectedA = projected.getProjected();
    Geometry projectedB = projected.projectAlso(latLongLineB);
    assertEquals(projectedA.getSRID(), projectedB.getSRID());
    assertThat(projectedA.getLength() + projectedB.getLength(), closeTo(200.0D, 0.1));
    assertThat(nztmHelper.reproject(projectedA), geometryMatch(nzLineA, METER_TOLERANCE_NEAREST_CM));
    assertThat(nztmHelper.reproject(projectedB), geometryMatch(nzLineB, METER_TOLERANCE_NEAREST_CM));

    // check it still works if the other geom is already in a metric CRS
    projectedB = projected.projectAlso(nzLineB);
    assertEquals(projectedA.getSRID(), projectedB.getSRID());
    assertThat(projectedA.getLength() + projectedB.getLength(), closeTo(200.0D, 0.1));
    assertThat(nztmHelper.reproject(projectedB), geometryMatch(nzLineB, METER_TOLERANCE_NEAREST_CM));
  }

  private Geometry apply(Geometry geom) {
    List<Projected> applied = subject.apply(geom);
    assertThat(applied, hasSize(1));
    return applied.get(0).getProjected();
  }

}
