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

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 static org.mockito.Mockito.*;

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

import org.geotools.geometry.jts.ReferencedEnvelope;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Point;

import nz.org.riskscape.engine.ArgsProblems;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.function.IdentifiedFunction.Category;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.engine.rl.BaseExpressionRealizerTest;
import nz.org.riskscape.engine.types.CoverageType;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.TypeProblems;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem;

@SuppressWarnings("unchecked")
public class SegmentByGridTest extends BaseExpressionRealizerTest {
  GeometryHelper nztmGeomHelper = GeometryHelper.nzTransverseMercatorWithOriginShift(project.getSridSet());
  GeometryHelper latLongGeomHelper = GeometryHelper.latLong(project.getSridSet());

  Struct inputType = Struct.of("geometry", Types.GEOMETRY,
      "point", Nullable.of(Types.POINT),
      "coverage", Nullable.of(CoverageType.WILD));

  @Before
  public void setup() {
    project.getFunctionSet().add(new SegmentByGrid().asFunction().builtin("segment_by_grid", Category.LANGUAGE));
  }

  @Test
  public void segmentsFromPointOrigin() {
    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.box(0, 0, 100, 100), nztmGeomHelper.point(50, 50));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result, containsInAnyOrder(
        geometryTopoMatch(nztmGeomHelper.box(0, 0, 50, 50)),
        geometryTopoMatch(nztmGeomHelper.box(0, 50, 50, 100)),
        geometryTopoMatch(nztmGeomHelper.box(50, 0, 100, 50)),
        geometryTopoMatch(nztmGeomHelper.box(50, 50, 100, 100))
    ));
  }

  @Test
  public void segmentsFromCoverageOrigin() {
    Point origin = nztmGeomHelper.point(50, 50);
    ReferencedEnvelope env = new ReferencedEnvelope(
        new Envelope(origin.getX(), origin.getX() + 1000, origin.getY(), origin.getY() + 1000),
        nztmGeomHelper.getCrs());
    TypedCoverage coverage = mock(TypedCoverage.class);
    when(coverage.getEnvelope()).thenReturn(Optional.of(env));
    when(coverage.getCoordinateReferenceSystem()).thenReturn(nztmGeomHelper.getCrs());

    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.box(0, 0, 100, 100), null, coverage);

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, coverage)", input);
    assertThat(result, containsInAnyOrder(
        geometryTopoMatch(nztmGeomHelper.box(0, 0, 50, 50)),
        geometryTopoMatch(nztmGeomHelper.box(0, 50, 50, 100)),
        geometryTopoMatch(nztmGeomHelper.box(50, 0, 100, 50)),
        geometryTopoMatch(nztmGeomHelper.box(50, 50, 100, 100))
    ));
  }

  @Test
  public void segmentsFromReprojectedPointOrigin() {
    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.box(0, 0, 100, 100),
        latLongGeomHelper.reproject(nztmGeomHelper.point(50, 50)));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result, containsInAnyOrder(
        geometryMatch(nztmGeomHelper.box(0, 0, 50, 50), METER_TOLERANCE_NEAREST_CM),
        geometryMatch(nztmGeomHelper.box(0, 50, 50, 100), METER_TOLERANCE_NEAREST_CM),
        geometryMatch(nztmGeomHelper.box(50, 0, 100, 50), METER_TOLERANCE_NEAREST_CM),
        geometryMatch(nztmGeomHelper.box(50, 50, 100, 100), METER_TOLERANCE_NEAREST_CM)
    ));
  }

  @Test
  public void segmentsFromCoverageOriginInDifferentCrs() {
    // input geometry is in lat/long, coverage is in NZTM
    Point origin = nztmGeomHelper.point(50, 50);
    Point envMax = nztmGeomHelper.point(50 + 1000, 50 + 1000);
    ReferencedEnvelope env = new ReferencedEnvelope(
        new Envelope(origin.getCoordinate(), envMax.getCoordinate()),
        nztmGeomHelper.getCrs());
    TypedCoverage coverage = mock(TypedCoverage.class);
    when(coverage.getEnvelope()).thenReturn(Optional.of(env));
    when(coverage.getCoordinateReferenceSystem()).thenReturn(nztmGeomHelper.getCrs());

    Tuple input = Tuple.ofValues(inputType, latLongGeomHelper.reproject(nztmGeomHelper.box(0, 0, 100, 100)), null,
        coverage);

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, coverage)", input);

    // convert the results back to NZTM centroid points, as it makes it a bit easier to check
    // (we don't have to worry about vertice order)
    List<Geometry> nztmResults = result.stream()
        .map(g -> nztmGeomHelper.reproject(g.getCentroid()))
        .collect(Collectors.toList());

    assertThat(nztmResults, containsInAnyOrder(
        geometryMatch(nztmGeomHelper.point(25, 25), 2.0),
        geometryMatch(nztmGeomHelper.point(25, 75), 2.0),
        geometryMatch(nztmGeomHelper.point(75, 25), 2.0),
        geometryMatch(nztmGeomHelper.point(75, 75), 2.0)));
  }

  @Test
  public void doesNotSegmentUnnecessarily() {
    // shape is less than grid size, so shouldn't get segmented
    Geometry inputGeom = nztmGeomHelper.box(5, 5, 95, 95);
    Tuple input = Tuple.ofValues(inputType, inputGeom, nztmGeomHelper.point(0, 0));
    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result, contains(geometryTopoMatch(inputGeom)));
  }

  @Test
  public void doesNotSegmentUnnecessarilyWithReprojectedOrigin() {
    Geometry inputGeom = nztmGeomHelper.box(5, 5, 95, 95);
    Tuple input = Tuple.ofValues(inputType, inputGeom,
        latLongGeomHelper.reproject(nztmGeomHelper.point(0, 0)));
    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result, contains(geometryTopoMatch(inputGeom)));
  }

  @Test
  public void doesNotSegmentUnnecessarilyWithReprojectedGeometry() {
    // the corners of the box shift a bit (up to 5m) during reprojection, which
    // causes the polygon to slant. This slant changes its envelope, so note that
    // a polygon within 5m of the origin will still get segmented
    Geometry inputGeom = latLongGeomHelper.reproject(nztmGeomHelper.box(5, 5, 95, 95));
    Tuple input = Tuple.ofValues(inputType, inputGeom, nztmGeomHelper.point(0, 0));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result.size(), is(1));
    assertThat(result, containsInAnyOrder(geometryMatch(inputGeom, DEGREE_TOLERANCE_NEAREST_MM)));
  }

  @Test
  public void doesNotSegmentUnnecessarilyWithEverythingReprojected() {
    Geometry inputGeom = latLongGeomHelper.reproject(nztmGeomHelper.box(5, 5, 95, 95));
    Tuple input = Tuple.ofValues(inputType, inputGeom, latLongGeomHelper.reproject(nztmGeomHelper.point(0, 0)));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result.size(), is(1));
    assertThat(result, containsInAnyOrder(geometryMatch(inputGeom, DEGREE_TOLERANCE_NEAREST_MM)));
  }

  @Test
  public void segmentsLinesToGrid() {
    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.line(
        nztmGeomHelper.toCoordinate(10, 200),
        nztmGeomHelper.toCoordinate(10, 10),
        nztmGeomHelper.toCoordinate(200, 10)
    ), nztmGeomHelper.point(0, 0));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    assertThat(result, containsInAnyOrder(
        geometryTopoMatch(nztmGeomHelper.line(10, 200, 10, 100)),
        geometryTopoMatch(nztmGeomHelper.line(100, 10, 200, 10)),
        geometryTopoMatch(nztmGeomHelper.line(
            nztmGeomHelper.toCoordinate(10, 100),
            nztmGeomHelper.toCoordinate(10, 10),
            nztmGeomHelper.toCoordinate(100, 10)
        ))
    ));
  }

  @Test
  public void segmentsReprojectedLineToGrid() {
    // line is less than 100m, but grid origin is halfway
    Tuple input = Tuple.ofValues(inputType, latLongGeomHelper.reproject(nztmGeomHelper.line(10, 0, 90, 0)),
        nztmGeomHelper.point(50, 50));

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, point)", input);
    // line should be cut in half
    assertThat(result.size(), is(2));

    // convert the results back to NZTM as it makes it a bit easier to check
    List<Geometry> nztmResults = result.stream()
        .map(g -> nztmGeomHelper.reproject(g))
        .collect(Collectors.toList());

    // overall we should have the same length line still
    assertThat(nztmResults.stream()
        .map(g -> g.getLength())
        .collect(Collectors.summingDouble(v -> v)), closeTo(80.0D, METER_TOLERANCE_NEAREST_CM));

    // line should've been cut roughly in the middle (allow plenty of leeway due to reprojection)
    assertThat(nztmResults, containsInAnyOrder(
        geometryMatch(nztmGeomHelper.line(10, 0, 50, 0), 2.5),
        geometryMatch(nztmGeomHelper.line(50, 0, 90, 0), 2.5)
    ));
  }

  @Test
  public void segmentsLinesFromReprojectedCoverageOrigin() {
    Point origin = (Point) latLongGeomHelper.reproject(nztmGeomHelper.point(50, 50));
    Point envMax = (Point) latLongGeomHelper.reproject(nztmGeomHelper.point(50 + 1000, 50 + 1000));
    ReferencedEnvelope env = new ReferencedEnvelope(
        new Envelope(origin.getCoordinate(), envMax.getCoordinate()),
        latLongGeomHelper.getCrs());
    TypedCoverage coverage = mock(TypedCoverage.class);
    when(coverage.getEnvelope()).thenReturn(Optional.of(env));
    when(coverage.getCoordinateReferenceSystem()).thenReturn(latLongGeomHelper.getCrs());

    Tuple input = Tuple.ofValues(inputType, latLongGeomHelper.reproject(nztmGeomHelper.line(0, 0, 200, 0)),
        null, coverage);

    List<Geometry> result = (List<Geometry>) evaluate("segment_by_grid(geometry, 100, coverage)", input);
    // line should be cut into three parts: 50m, 100m, 50m
    assertThat(result.size(), is(3));

    // convert the results back to NZTM as it makes it a bit easier to check
    List<Geometry> nztmResults = result.stream()
        .map(g -> nztmGeomHelper.reproject(g))
        .collect(Collectors.toList());

    // overall we should have the same length line still
    assertThat(nztmResults.stream()
        .map(g -> g.getLength())
        .collect(Collectors.summingDouble(v -> v)), closeTo(200.0D, METER_TOLERANCE_NEAREST_CM));

    // check line got cut roughly in the right places (allow plenty of leeway due to reprojection)
    assertThat(nztmResults, containsInAnyOrder(
        geometryMatch(nztmGeomHelper.line(0, 0, 50, 0), 2.5),
        geometryMatch(nztmGeomHelper.line(50, 0, 150, 0), 2.5),
        geometryMatch(nztmGeomHelper.line(150, 0, 200, 0), 2.5)
    ));
  }

  @Test
  public void returnsNullWhenToAlignIsNull() {
    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.box(0, 0, 100, 100));

    assertNull(evaluate("segment_by_grid(geometry, 100, point)", input));
    assertTrue(realizationProblems.isEmpty());

    assertNull(evaluate("segment_by_grid(geometry, 100, coverage)", input));
    assertTrue(realizationProblems.isEmpty());
  }

  @Test
  public void failsWhenArgsWrongType() {
    Tuple input = Tuple.ofValues(inputType, nztmGeomHelper.box(0, 0, 100, 100), nztmGeomHelper.point(50, 50));

    evaluate("segment_by_grid('bobby', 100, point)", input);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(isProblem(Problem.Severity.ERROR, ArgsProblems.class, "realizableDidNotMatch"))
    ));

    evaluate("segment_by_grid(geometry, 'one hundred', point)", input);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(isProblem(Problem.Severity.ERROR, ArgsProblems.class, "realizableDidNotMatch"))
    ));

    evaluate("segment_by_grid(geometry, 100, 'the origin')", input);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(isProblem(Problem.Severity.ERROR, TypeProblems.class, "requiresOneOf"))
    ));
  }

  @Test
  public void failsWhenWrongNumberOfArguments() {
    evaluate("segment_by_grid()", Tuple.EMPTY_TUPLE);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(equalTo(ArgsProblems.get().wrongNumber(3, 0)))
    ));

    evaluate("segment_by_grid('dummy')", Tuple.EMPTY_TUPLE);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(equalTo(ArgsProblems.get().wrongNumber(3, 1)))
    ));

    evaluate("segment_by_grid('dummy', 'dummy')", Tuple.EMPTY_TUPLE);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(equalTo(ArgsProblems.get().wrongNumber(3, 2)))
    ));

    evaluate("segment_by_grid('dummy', 'dummy', 'dummy', 'dummy')", Tuple.EMPTY_TUPLE);
    assertThat(realizationProblems, contains(
        hasAncestorProblem(equalTo(ArgsProblems.get().wrongNumber(3, 4)))
    ));
  }

}
