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

import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.geotools.api.referencing.operation.TransformException;
import org.geotools.coverage.grid.GridCoordinates2D;
import org.geotools.coverage.grid.GridEnvelope2D;
import org.geotools.coverage.grid.GridGeometry2D;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.referencing.CRS;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Test;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.gt.GeometryHelper;

public class FeatureGridTest extends ProjectTest {

  GeometryHelper nzxyGeomHelper = GeometryHelper.nzTransverseMercatorXYWithOriginShift(project.getSridSet());
  GeometryHelper latLongGeomHelper = GeometryHelper.latLong(project.getSridSet());

  ReferencedEnvelope defaultBounds = nzxyGeomHelper.envelope(
      nzxyGeomHelper.toCoordinate(0, 0),
      nzxyGeomHelper.toCoordinate(1200, 900)
  );

  GridEnvelope2D defaultEnvelope = new GridEnvelope2D(0, 0, 4, 3);

  FeatureGrid grid;
  List<FeatureGridCell> gridCells;

  @Test
  public void buildsFourByThreeGrid() throws Exception {
    // test the feature grid when given a feature that fills all the cells
    grid(nzxyGeomHelper.box(100, 100, 1100, 800), defaultBounds, defaultEnvelope);
    assertThat(grid.getFeatureGridEnvelopeInWorldCrs(), is(nzxyGeomHelper.envelope(
        nzxyGeomHelper.toCoordinate(0, 0),
        nzxyGeomHelper.toCoordinate(1200, 900)
    )));
    assertThat(grid.getEnvelope(), is(new Envelope(0D, 4D, 0D, 3D)));
    assertThat(grid.getFeatureGridEnvelopeInWorldCrs(), is(defaultBounds));
    assertThat(grid.getCellHeight(), is(-300D));
    assertThat(grid.getCellWidth(), is(300D));
    assertThat(grid.getGridColumns(), is(4));
    assertThat(grid.getGridRows(), is(3));
  }

  @Test
  public void buildsOneByOneGrid() throws Exception {
    // test the feature grid when given a feature that fills all the cells
    Polygon fillsGrid = nzxyGeomHelper.box(100, 100, 1100, 800);
    grid(fillsGrid, defaultBounds, new GridEnvelope2D(0, 0, 1, 1));
    assertThat(grid.getFeatureGridEnvelopeInWorldCrs(), is(nzxyGeomHelper.envelope(
        nzxyGeomHelper.toCoordinate(0, 0),
        nzxyGeomHelper.toCoordinate(1200, 900)
    )));
    assertThat(grid.getEnvelope(), is(new Envelope(0D, 1D, 0D, 1D)));
    assertThat(grid.getCellHeight(), is(-900D));
    assertThat(grid.getCellWidth(), is(1200D));
    assertThat(grid.getGridColumns(), is(1));
    assertThat(grid.getGridRows(), is(1));

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(600, 450), fillsGrid, 0, 0)
    ));
  }

  @Test
  public void canGridGeometryThatFitsWithinOneGridCell() throws Exception {
    Polygon fillsCell = nzxyGeomHelper.box(400, 400, 500, 500);

    grid(fillsCell, defaultBounds, defaultEnvelope);
    assertThat(grid.getEnvelope(), is(new Envelope(1, 2, 1, 2)));
    assertThat(grid.getFeatureGridEnvelopeInWorldCrs(), is(nzxyGeomHelper.envelope(
        nzxyGeomHelper.toCoordinate(300, 300),
        nzxyGeomHelper.toCoordinate(600, 600)
    )));

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450), fillsCell, 1, 1)
    ));
  }

  @Test
  public void canGridGeometryThatSpansTwoGridCells() throws Exception {
    Polygon fillsCell = nzxyGeomHelper.box(400, 400, 700, 500);

    grid(fillsCell, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450),
            nzxyGeomHelper.box(400, 400, 600, 500), 1, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(750, 450),
            nzxyGeomHelper.box(600, 400, 700, 500), 2, 1)
    ));
  }

  @Test
  public void canGridPointThatFallsInsideGridCell() throws Exception {
    Point point = nzxyGeomHelper.point(50, 50);
    grid(point, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            point, 0, 2)
    ));
  }

  @Test
  public void canGridPointThatSitsOnVerticalGridLine() throws Exception {
    Point point = nzxyGeomHelper.point(300, 50);
    grid(point, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 150),
            point, 1, 2)
    ));
  }

  @Test
  public void canGridPointThatSitsOnIntersectionOfGridLines() throws Exception {
    Point point = nzxyGeomHelper.point(300, 300);
    grid(point, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        // only get one of the potential four grid cells
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450), point, 1, 1)
    ));
  }

  @Test
  public void canGridPointThatSitsOnHorizontalGridLine() throws Exception {
    Point point = nzxyGeomHelper.point(50, 300);
    grid(point, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 450),
            point, 0, 1)
    ));
  }

  @Test
  public void hasGridCellsForPointsOnLeftOrBottomOfGridExterior() throws Exception {
    Point leftExterior = nzxyGeomHelper.point(0, 100);
    grid(leftExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            leftExterior, 0, 2)
    ));

    Point bottomExterior = nzxyGeomHelper.point(100, 0);
    grid(bottomExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            bottomExterior, 0, 2)
    ));
  }

  @Test
  public void hasNoGridCellsForPointOnRightOrTopOfGridExterior() throws Exception {
    // because of the way FeatureGrid.buildGridEnvelope builds envelopes point that lay on the right or top
    // exterior of the grid don't have any grid cells
    Point rightExterior = nzxyGeomHelper.point(1200, 0);
    grid(rightExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, empty());

    Point topExterior = nzxyGeomHelper.point(0, 900);
    grid(topExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, empty());
  }

  @Test
  public void hasGridCellsForLinesOnLeftOrBottomOfGridExterior() throws Exception {
    LineString leftExterior = nzxyGeomHelper.line(0, 100, 0, 800);
    grid(leftExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 750),
            nzxyGeomHelper.line(0, 600, 0, 800), 0, 0),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 450),
            nzxyGeomHelper.line(0, 300, 0, 600), 0, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            nzxyGeomHelper.line(0, 100, 0, 300), 0, 2)
    ));

    LineString bottomExterior = nzxyGeomHelper.line(100, 0, 700, 0);
    grid(bottomExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            nzxyGeomHelper.line(100, 0, 300, 0), 0, 2),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 150),
            nzxyGeomHelper.line(300, 0, 600, 0), 1, 2),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(750, 150),
            nzxyGeomHelper.line(600, 0, 700, 0), 2, 2)
    ));
  }

  @Test
  public void hasNoGridCellsForLinesOnRightOrTopOfGridExterior() throws Exception {
    // because of the way FeatureGrid.buildGridEnvelope builds envelopes point that lay on the right or top
    // exterior of the grid don't have any grid cells
    LineString rightExterior = nzxyGeomHelper.line(1200, 0, 1200, 700);
    grid(rightExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, empty());

    LineString topExterior = nzxyGeomHelper.line(0, 900, 500, 900);
    grid(topExterior, defaultBounds, defaultEnvelope);

    assertThat(gridCells, empty());
  }

  @Test
  public void canGridLineThatExtendsBeyondGrid1() throws Exception {
    grid(nzxyGeomHelper.line(-100, 100, 100, 100), defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            nzxyGeomHelper.line(0, 100, 100, 100), 0, 2)
    ));
  }

  @Test
  public void canGridLineThatExtendsBeyondGrid2() throws Exception {
    // this test seems rather pointless being almost the same as canGridLineThatExtendsBeyondGrid1
    // but this test will fail if buildGridEnvelope does not clip the feature envelope to by the grid envelope
    grid(nzxyGeomHelper.line(100, -100, 100, 100), defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            nzxyGeomHelper.line(100, 0, 100, 100), 0, 2)
    ));
  }

  @Test
  public void canGridLineThatRunsAcrossGridLine() {
    LineString line = nzxyGeomHelper.line(100, 300, 400, 300);
    grid(line, defaultBounds, defaultEnvelope);

    assertThat(gridCells, containsInAnyOrder(
        // note we don't get cells from below the grid line. this is probably a good
        // thing else we could end up double counting
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 450),
            nzxyGeomHelper.line(100, 300, 300, 300), 0, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450),
            nzxyGeomHelper.line(300, 300, 400, 300), 1, 1)
    ));

  }

  @Test
  public void canGridLineThatRunsUpGridLine() {
    LineString line = nzxyGeomHelper.line(300, 100, 300, 400);
    grid(line, defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        // note we don't get cells for the left side of the grid line. this is probably a good
        // thing else we could end up double counting
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450),
            nzxyGeomHelper.line(300, 300, 300, 400), 1, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 150),
            nzxyGeomHelper.line(300, 100, 300, 300), 1, 2)
    ));
  }

  @Test
  public void canGridLineThatPassesThroughGrid() throws Exception {
    grid(nzxyGeomHelper.line(
        nzxyGeomHelper.toCoordinate(-100, 100),
        nzxyGeomHelper.toCoordinate(400, 100),
        nzxyGeomHelper.toCoordinate(400, 1000)
    ), defaultBounds, defaultEnvelope);

    assertThat(gridCells, containsInAnyOrder(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 750), nzxyGeomHelper.emptyLine(), 0, 0),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 750),
            nzxyGeomHelper.line(400, 600, 400, 900), 1, 0),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 450), nzxyGeomHelper.emptyLine(), 0, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 450),
            nzxyGeomHelper.line(400, 300, 400, 600), 1, 1),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150),
            nzxyGeomHelper.line(0, 100, 300, 100), 0, 2),
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(450, 150), nzxyGeomHelper.line(
            nzxyGeomHelper.toCoordinate(300, 100),
            nzxyGeomHelper.toCoordinate(400, 100),
            nzxyGeomHelper.toCoordinate(400, 300)
        ), 1, 2)
    ));
  }

  @Test
  public void canGridGeometryThatExtendsBeyondGrid() throws Exception {
    grid(nzxyGeomHelper.box(-100, -100, 200, 200), defaultBounds, defaultEnvelope);

    assertThat(gridCells, contains(
        cellWithCentreIntersectinAndGridXY(nzxyGeomHelper.point(150, 150), nzxyGeomHelper.box(0, 0, 200, 200), 0, 2)
    ));
  }

  @Test
  public void canGridInLatLong() {
    // smoke test for lat/long (Y/X) projections
    defaultBounds = latLongGeomHelper.envelope(
        latLongGeomHelper.toCoordinate(10, 170),
        latLongGeomHelper.toCoordinate(11, 171)
    );
    // make the grid rectangular so we check height/width applied to right axis
    defaultEnvelope = new GridEnvelope2D(0, 0, 10, 5);

    Point testPoint = latLongGeomHelper.point(10.35, 170.75);
    grid(testPoint, defaultBounds, defaultEnvelope);

    assertThat(grid.getEnvelope(), is(new Envelope(7D, 8D, 3D, 4D)));
    assertThat(grid.getFeatureGridEnvelopeInWorldCrs(), is(latLongGeomHelper.envelope(
        latLongGeomHelper.toCoordinate(10.200000000000001, 170.70000000000002),
        latLongGeomHelper.toCoordinate(10.4, 170.8)
    )));
    assertThat(grid.getCellHeight(), closeTo(-0.2D, 0.001));
    assertThat(grid.getCellWidth(), closeTo(0.1D, 0.001));
    assertThat(grid.getGridColumns(), is(1));
    assertThat(grid.getGridRows(), is(1));

    assertThat(gridCells, contains(
        // 10.3 Y, 170.75 = X in lat/long
        cellWithCentreIntersectinAndGridXY(latLongGeomHelper.point(10.3, 170.75), testPoint, 7, 3)
    ));
  }

  @Test
  public void griddingFeatureOutsideOfGridHasNoGridCells() {
    grid(nzxyGeomHelper.point(10_000, 10_000), defaultBounds, defaultEnvelope);

    assertThat(gridCells, empty());
    assertThat(grid.getGridColumns(), is(0));
    assertThat(grid.getGridRows(), is(0));
    // The envelope is empty
    assertThat(grid.getEnvelope(), is(new Envelope()));
  }

  private void grid(Geometry geom, ReferencedEnvelope bounds, GridEnvelope2D envelope) {
    try {
      grid = new FeatureGrid(
          geom,
          CRS.getAxisOrder(bounds.getCoordinateReferenceSystem()),
          new GridGeometry2D(envelope, bounds)
      );
      gridCells = new ArrayList<>();
      Iterator<FeatureGridCell> it = grid.cellIterator();
      while (it.hasNext()) {
        gridCells.add(it.next());
      }
    } catch (TransformException e) {
      throw new RuntimeException(e);
    }
  }

  private Matcher<FeatureGridCell> cellWithCentreIntersectinAndGridXY(Point centre, Geometry intersection,
      int x, int y) {
    return cellWithCentreIntersectinAndGridPosition(
        is(centre),
        // match on either is or topo match because topo match does not seem to handle empty geometries
        either(is(intersection)).or(GeometryMatchers.geometryTopoMatch(intersection)),
        is(new GridCoordinates2D(x, y))
    );
  }

  private Matcher<FeatureGridCell> cellWithCentreIntersectinAndGridPosition(Matcher<Point> centre,
      Matcher<Geometry> intersection, Matcher<GridCoordinates2D> gridPosition) {
    return new TypeSafeMatcher<FeatureGridCell>() {
      @Override
      protected boolean matchesSafely(FeatureGridCell subject) {
        return intersection.matches(subject.computeIntersection())
            && centre.matches(subject.getCellCentre())
            && gridPosition.matches(subject.getGridPosition());
      }

      @Override
      public void describeTo(Description d) {
        d.appendText("cell at grid position ").appendValue(gridPosition);
      }

      @Override
      protected void describeMismatchSafely(FeatureGridCell subject, Description mismatchDescription) {
        if (!intersection.matches(subject.computeIntersection())) {
          mismatchDescription.appendText("intersection: ");
          intersection.describeMismatch(subject.computeIntersection(), mismatchDescription);
        }
        if (!centre.matches(subject.getCellCentre())) {
          mismatchDescription.appendText(" centre: ");
          centre.describeMismatch(subject.getCellCentre(), mismatchDescription);
        }
        if (!gridPosition.matches(subject.getGridPosition())) {
          mismatchDescription.appendText(" grid position: ");
          gridPosition.describeMismatch(subject.getGridPosition(), mismatchDescription);
        }
      }

    };
  }
}
