/*
 * 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 org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;

import org.junit.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.impl.PackedCoordinateSequence;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.geo.RecursiveQuadGridOp.Result;

public class RecursiveQuadGridOpTest {

  GeometryFactory gf = new GeometryFactory();
  RecursiveQuadGridOp subject = new RecursiveQuadGridOp();

  Polygon square = ring(
      -5, -5,
      5, -5,
      5, 5,
      -5, 5,
      -5, -5
  );

  Polygon uShape = ring(
      1,  8,
      17, 8,
      17, 2,
      1, 2,
      1, 4,
      13, 4,
      13, 6,
      1, 6,
      1, 8
  );

  @Test
  public void testSnapTo() throws Exception {
    // already aligned
    assertThat(RecursiveQuadGridOp.snapTo(1D, 0D, 1D), is(1D));
    assertThat(RecursiveQuadGridOp.snapTo(3.5, 0.5, 3D), is(3.5));

    // align to the value that's closest
    assertThat(RecursiveQuadGridOp.snapTo(1.1, 0.5, 1D), is(1.5));
    assertThat(RecursiveQuadGridOp.snapTo(0.9, 0.5, 1D), is(0.5));
    assertThat(RecursiveQuadGridOp.snapTo(2.4, 10000.5, 2D), closeTo(2.5, 0.00001));
    assertThat(RecursiveQuadGridOp.snapTo(0.6, 10000.5, 2D), closeTo(0.5, 0.00001));
    assertThat(RecursiveQuadGridOp.snapTo(1.0, 1.75, 1D), is(0.75));

    // negative coordinates
    assertThat(RecursiveQuadGridOp.snapTo(-1.1, -0.5, 1D), is(-1.5));
    assertThat(RecursiveQuadGridOp.snapTo(-0.9, -0.5, 1D), is(-0.5));
    assertThat(RecursiveQuadGridOp.snapTo(-2.4, -10000.5, 2D), closeTo(-2.5, 0.00001));
    assertThat(RecursiveQuadGridOp.snapTo(-0.6, -10000.5, 2D), closeTo(-0.5, 0.00001));

    // check values that straddle a boundary
    assertThat(RecursiveQuadGridOp.snapTo(0.1, -0.5, 2D), is(-0.5));
    assertThat(RecursiveQuadGridOp.snapTo(0.9, -0.5, 2D), is(1.5));
    assertThat(RecursiveQuadGridOp.snapTo(-0.1, 0.5, 2D), is(0.5));
    assertThat(RecursiveQuadGridOp.snapTo(-0.9, 0.5, 2D), is(-1.5));
  }

  @Test
  public void testEnvelopeSnapping() throws Exception {
    // This is the first half of envelope expansion - it centres the envelope on a defined grid while making sure it
    // still covers the original envelope
    Envelope env = envelope(0, 0, 2, 2);

    // noop - this is perfectly aligned already
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(1, 1));
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(2, 2)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(0, 0)));

    // also a noop - this is perfectly aligned already, but to a smaller grid
    env = envelope(0, 0, 2, 2);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 0.5, new Coordinate(1, 1));
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(2, 2)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(0, 0)));

    // this time, we need to be aligned to a 0.5 shift
    env = envelope(0, 0, 2, 2);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(1.5, 1.5));
    // NB we were off by 0.5, so the new aligned centre gets rounded up
    assertThat(env.centre(), equalTo(new Coordinate(1.5, 1.5)));
    // envelope increases by 2 x delta so BL of original envelope is still contained
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(3, 3)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(0, 0)));

    // this time align to a 0.25 shift to the top right
    env = envelope(0, 0, 2, 2);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(1.25, 1.25));
    assertThat(env.centre(), equalTo(new Coordinate(1.25, 1.25)));
    // note that the new envelope won't be in
    // multiples of grid size yet - that comes after
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(2.5, 2.5)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(0, 0)));

    // align to a -0.25 shift to the bottom left
    env = envelope(0, 0, 2, 2);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(1.75, 1.75));
    assertThat(env.centre(), equalTo(new Coordinate(0.75, 0.75)));
    // note that the new envelope won't be in
    // multiples of grid size yet - that comes after
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(2.0, 2.0)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(-0.5, -0.5)));

    // a wacky grid coordinate, but still a noop
    env = envelope(0, 0, 2, 2);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(-10, 8));
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(2, 2)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(0, 0)));

    // a wacky grid coordinate, and an off-centre envelope, but still a no-op
    env = envelope(160, 180, -544, 866);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 1, new Coordinate(-10, 8));
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(160, 866)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(-544, 180)));

    // the centre should move from 0,0 to -3,4 here
    env = envelope(-30, -40, 30, 40);
    RecursiveQuadGridOp.snapEnvelopeCentreTo(env, 10, new Coordinate(-3, 4));
    assertThat(env.centre(), equalTo(new Coordinate(-3, 4)));
    // the new envelope still contains original bounds at 30,40 and -30,-40
    assertThat(Quadrant.TR.getCoordinate(env), equalTo(new Coordinate(30, 48)));
    assertThat(Quadrant.BL.getCoordinate(env), equalTo(new Coordinate(-36, -40)));
  }

  @Test
  public void testEnvelopeExpandForFactor() throws Exception {
    // envelope already aligned, but increases by 1 in each direction
    Envelope env = envelope(1, 1, 7, 7);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 1, new Coordinate(4, 4)), is(2));
    assertThat(env, equalTo(envelope(0, 0, 8, 8)));

    // different grid-size so less recursion, but should be same envelope
    env = envelope(1, 1, 7, 7);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 4, new Coordinate(4, 4)), is(0));
    assertThat(env, equalTo(envelope(0, 0, 8, 8)));

    // grid size (10) is larger than width, so it expands from 0,0 by 10 in each direction
    env = envelope(-4, -4, 4, 4);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 10, new Coordinate(0, 0)), is(0));
    assertThat(env, equalTo(envelope(-10, -10, 10, 10)));

    // sanity-check envelope still gets aligned
    env = envelope(1, 1, 7, 7);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 1, new Coordinate(4.5, 4.5)), is(2));
    assertThat(env, equalTo(envelope(0.5, 0.5, 8.5, 8.5)));

    // for a non-square, the longest side should get used (i.e. 10 => 16)
    env = envelope(-5, -4, 5, 4);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 1, new Coordinate(0, 0)), is(3));
    // and the result is now a square
    assertThat(env, equalTo(envelope(-8, -8, 8, 8)));

    // same, but other side is longer
    env = envelope(-4, -5, 4, 5);
    assertThat(RecursiveQuadGridOp.expandForFactor(env, 1, new Coordinate(0, 0)), is(3));
    assertThat(env, equalTo(envelope(-8, -8, 8, 8)));
  }

  Result apply(Geometry geom, double size) {
    return subject.applyDetailed(geom, size, geom.getEnvelopeInternal().centre());
  }

  @Test
  public void testASquareCutsInToOnlyBlocks() throws Exception {
    // we end up with just regular squares and no leftover cuts
    Result result = apply(square, 1);
    assertThat(result.cuts, empty());
    assertThat(result.squares.getBlocks(), hasSize(40));
    assertThat(result.getCombinedResult(), hasSize(100));

    // check the corners
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(-5, -5, -4, -4))));
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(5, 5, 4, 4))));

    // random bit from the middle
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(0, 1, 1, 2))));
  }

  @Test
  public void testASquareWithCutsAndBlocks() {
    // do it again, but with a different grid size. We'll end up with 1x1 and 1x2 pieces at the edge
    Result result = apply(square, 2);
    assertThat(result.cuts, hasSize(20));
    assertThat(result.squares.getBlocks(), hasSize(4));
    assertThat(result.getCombinedResult(), hasSize(36));

    // check the corners - notice they are still size 1
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(-5, -5, -4, -4))));
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(5, 5, 4, 4))));

    // check a side piece - should be size 2
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(4, -2, 5, 0))));

    // random bit from the middle - this has size 2
    assertThat(result.getCombinedResult(), hasItem(GeometryMatchers.geometryTopoMatch(square(0, 0, 2, 2))));
  }

  @Test
  public void testASquareWithCutsAndBlocksAligned() {
    // do it again, but with aligned to -5,5 instead of 0,0, which'll snap the centre to 1,1
    // the square will get quadded perfectly into 2s and 4s with no leftover bits
    Result result = subject.applyDetailed(square, 2, Quadrant.TL.getCoordinate(square.getEnvelopeInternal()));
    assertThat(result.cuts, hasSize(0));
    assertThat(result.squares.getBlocks(), hasSize(13));
    assertThat(result.getCombinedResult(), hasSize(25));

    // don't bother asserting the geometry, just check the area adds up and the combined envelope matches the original
    // one
    assertThat(
      result.getCombinedResult().stream().collect(Collectors.summingDouble(Geometry::getArea)),
      equalTo(square.getArea())
    );
  }


  @Test
  public void testARealWorldPolygon() throws Exception {
    byte[] southAuckland = Files.readAllBytes(Path.of("src", "test", "resources", "south-auckland.wkt"));
    WKTReader reader = new WKTReader();
    Geometry geometry = reader.read(new String(southAuckland));

    Result result = apply(geometry, 0.01);

    // these number was found from the csv below and checked by opening the csv in qgis and eyeballing the generated
    // geometry
    assertThat(result.getCombinedResult().size(), equalTo(49329));
    assertThat(result.cuts.size(), equalTo(1867));
    assertThat(result.getBlockList().size(), equalTo(1631));

    // uncomment me to eyeball the results
//    writeList("combined", result.getCombinedResult());
//    writeList("cuts", result.cuts);
//    writeList("blocks", result.getBlockList());
  }

  void writeList(String name, List<? extends Geometry> geoms) throws IOException {
    WKTWriter writer = new WKTWriter();
    Writer fileWriter = new FileWriter(name + ".csv");

    int id = 1;
    fileWriter.write("id,wkt\n");
    for (Geometry cut : geoms) {
      fileWriter.write(id++ + ",\"");
      writer.write(cut, fileWriter);
      fileWriter.write("\"\n");
    }

    fileWriter.close();
  }

  @Test
  public void testARealWorldLineString() throws Exception {
    byte[] randomRoad = Files.readAllBytes(Path.of("src", "test", "resources", "random-road.wkt"));
    WKTReader reader = new WKTReader();
    Geometry geometry = reader.read(new String(randomRoad));

    Result result = subject.applyDetailed(geometry, 0.01);

    // these number was found from the csv below and checked by opening the csv in qgis and eyeballing the generated
    // geometry
    // Note that this gives a cut that doesn't seem 'proper', but that's because there's a point on the bottom that
    // touches the centre of our envelope, so it gets cut there somewhat unnecessarily - we could shift then centre
    // point to avoid this, but it's an interesting side effect that will happen in 'real life' if a linestring is
    // segmented like this
    assertThat(result.cuts.size(), equalTo(4));
    assertThat(result.getBlockList().size(), equalTo(0));

    // uncomment me to eyeball the results
//    writeList("combined", result.getCombinedResult());
//    writeList("cuts", result.cuts);
//    writeList("envelope", Collections.singletonList(
//        gf.createPolygon(RecursiveQuadGridOp.envelopeToRing(gf, result.getStartingEnvelope()))));

  }

  @Test
  public void testEmptyPolygonReturnsAnEmptyPolygon() throws Exception {
    // See #1603 - this helps us avoid skipping exposures after segmenting and unnesting
    assertThat(subject.apply(gf.createPolygon(), 1), contains(gf.createPolygon()));
  }

  Envelope envelope(double minx, double miny, double maxx, double maxy) {
    return square(minx, miny, maxx, maxy).getEnvelopeInternal();
  }

  Polygon square(double minx, double miny, double maxx, double maxy) {
    return ring(
        minx, miny,
        minx, maxy,
        maxx, maxy,
        maxx, miny,
        minx, miny
    );
  }

  Polygon ring(double... dims) {
    return gf.createPolygon(gf.createLinearRing(new PackedCoordinateSequence.Double(dims, 2, 0)));
  }

}
