/*
 * 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.awt.Color;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

import javax.imageio.ImageIO;

import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.api.style.FeatureTypeStyle;
import org.geotools.api.style.Style;
import org.geotools.data.collection.ListFeatureCollection;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.map.FeatureLayer;
import org.geotools.map.Layer;
import org.geotools.map.MapContent;
import org.geotools.map.MapViewport;
import org.geotools.renderer.GTRenderer;
import org.geotools.renderer.lite.StreamingRenderer;
import org.geotools.styling.StyleBuilder;
import org.junit.Ignore;
import org.junit.Test;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.operation.valid.IsValidOp;

import com.csvreader.CsvReader;

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.gt.GeometryHelper;

public class GeometryFixerTest {

  GeometryFixer fixer = GeometryFixer.DEFAULT;

  AtomicReference<Geometry> mockResult = new AtomicReference<>();
  // a fixer with a mock implementation. allows us to easily test the logic in GeometryFixer.Base.
  GeometryFixer mockFixer = new GeometryFixer.Base(geom -> mockResult.get());

  private static final SRIDSet SRIDSET = new SRIDSet();
  private static final GeometryHelper HELPER = GeometryHelper.create(SRIDSET, SRIDSet.WILDCARD_2D);

  /**
   * a bowtie shape, but there is no node at the knot which makes the geometry invalid.
   */
  public static final Geometry INVALID_BOWTIE = HELPER.fromWkt("POLYGON((0 0, 100 100, 0 100, 100 0, 0 0))");

  /**
   * Taken from https://postgis.net/workshops/postgis-intro/validity.html#what-is-validity this shape could
   * be thought of as a banana that is bent so much that the ends are touching.
   */
  public static final Geometry INVALID_BANANA =
      HELPER.fromWkt("POLYGON((0 0, 2 0, 1 1, 2 2, 3 1, 2 0, 4 0, 4 4, 0 4, 0 0))");

  /**
   * A point that is invalid and cannot be fixed. Useful in other tests.
   */
  public static final Geometry INVALID_POINT_UNFIXABLE = HELPER.point(Double.NaN, 0);

  @Test
  public void willNotFixTheUnFixable() {
    // sanity check it's not valid
    assertFalse(INVALID_POINT_UNFIXABLE.isValid());

    assertThat(fixer.fix(INVALID_POINT_UNFIXABLE), nullValue());
  }

  @Test
  public void canFixBowTieWithUnNodedKnot() {
    assertThat(fixInvalid(INVALID_BOWTIE), GeometryMatchers.isGeometry(
        HELPER.fromWkt("MULTIPOLYGON(((0 0, 50 50, 100 0, 0 0)), ((0 100, 50 50, 100 100, 0 100)))")));

  }

  @Test
  public void canFixBananaPolygon() {
    Geometry fixed = fixInvalid(INVALID_BANANA);
    assertThat(fixed, GeometryMatchers.isGeometry(
        // the fixed geom is a square exterior polygon, with a diamond shaped hole
        HELPER.fromWkt("POLYGON ((0 0, 0 4, 4 4, 4 0, 2 0, 0 0), (2 0, 3 1, 2 2, 1 1, 2 0))")));

  }

  @Test
  public void canFixPolygonWithLargeHoleOnExterior() {
    // think of a Rubik's cube with the bottom middle square removed (by the hole)
    // Note, polygon holes are only allowed to touch the exterior at a point (this one shares a line segment)
    Geometry fixed = fixInvalid(HELPER.fromWkt("POLYGON ((0 0, 0 3, 3 3, 3 0, 0 0), (1 0, 2 0, 2 2, 1 2, 1 0))"));
    assertThat(fixed, GeometryMatchers.isGeometry(
        // the fixed geom is a square exterior polygon, with a diamond shaped hole
        HELPER.fromWkt("POLYGON ((0 3, 3 3, 3 0, 2 0, 2 2, 1 2, 1 0, 0 0, 0 3))")));
  }

  @Test
  public void canFixMultiPolygonWithTwoPartsThatConnect() {
    Geometry fixed = fixInvalid(HELPER.fromWkt("MULTIPOLYGON(((0 0, 100 0, 100 100, 0 100, 0 0)), "
        + "((100 20, 200 20, 200 80, 100 80, 100 20)))"));
    assertThat(fixed, GeometryMatchers.isGeometry(
        // the fixed geom is a square exterior polygon, with a diamond shaped hole
        HELPER.fromWkt("POLYGON ((0 0, 100 0, 100 20, 200 20, 200 80, 100 80, 100 100, 0 100, 0 0))")));
  }

  @Test
  public void polygonsDoNotColapseToLinesOrPoints() {
    Geometry invalid = HELPER.fromWkt(
        "POLYGON ((10 10, 10 90, 90 90, 10 90, 10 10), (20 80, 60 80, 60 40, 20 40, 20 80))");
    assertFalse(invalid.isValid());
    assertThat(fixer.fix(invalid), nullValue());

    invalid = HELPER.fromWkt(
        "POLYGON ((10 10, 10 NaN, 90 NaN, 10 NaN, 10 10))");
    assertFalse(invalid.isValid());
    assertThat(fixer.fix(invalid), nullValue());
  }

  @Test
  public void canFixPolygons() throws Exception {
    // this test is reading invalid polygons (and a fixed version) from invalid.csv.
    // for each polygon that has a fixed version we check that the GeometryFixer will obtain the same
    // fixed geometry.
    // the purpose of this test is to highlight to us if JTS changes the geometry fixer so we can check
    // the results still look okay.
    // to view what these geoms look like some web tools exist like:
    // http://dev.openlayers.org/sandbox/docs/examples/wkt.html
    CsvReader reader = getInvalidCsv();
    while (reader.readRecord()) {
      String[] values = reader.getValues();
      String description = values[0].trim();
      String invalidWkt = values[1].trim();
      String fixedWkt = values[3].trim();

      if (! "".equals(fixedWkt)) {
        Geometry invalid = HELPER.fromWkt(invalidWkt);
        Geometry expectedFixed = HELPER.fromWkt(fixedWkt);

        assertThat(description, fixInvalid(invalid), GeometryMatchers.isGeometry(expectedFixed));
      }
    }
  }

  @Test
  public void invalidCoordinatesAreDiscarded() {
    Geometry invalid = HELPER.line(
        HELPER.toCoordinate(0, 0),
        HELPER.toCoordinate(Double.NaN, 0),
        HELPER.toCoordinate(0, Double.NaN),
        HELPER.toCoordinate(10, 0)
    );
    assertThat(fixer.fix(invalid), GeometryMatchers.isGeometry(HELPER.line(0, 0, 10, 0)));
  }

  @Test
  public void duplicatedCoordinatesAreDiscarded() {
    Geometry invalid = HELPER.line(
        HELPER.toCoordinate(0, 0),
        HELPER.toCoordinate(0, 0),
        HELPER.toCoordinate(10, 0),
        HELPER.toCoordinate(10, 0)
    );
    assertThat(fixer.fix(invalid), GeometryMatchers.isGeometry(HELPER.line(0, 0, 10, 0)));
  }

  @Test
  public void notEnoughValidCooridinatesMeansNoFix() {
    assertNull(fixer.fix(HELPER.point(0, Double.NaN)));

    // we want to ensure that a line with only one valid coordinate doesn't become a point
    assertNull(fixer.fix(HELPER.line(0, 0, 10, Double.NaN)));

    // a polygon needs at least three valid vertexes. This one only has 2.
    assertNull(fixer.fix(HELPER.box(
        HELPER.toCoordinate(0, 0),
        HELPER.toCoordinate(0, Double.NaN),
        HELPER.toCoordinate(10, 0),
        HELPER.toCoordinate(0, 0)
    )));
  }

  @Test
  public void doesNotChangeGeometryFamily() {
    mockResult.set(HELPER.line(0, 0, 0, 10));
    assertThat(mockFixer.fix(INVALID_BOWTIE), nullValue());

    mockResult.set(HELPER.point(0, 10));
    assertThat(mockFixer.fix(INVALID_BOWTIE), nullValue());
  }

  @Test
  public void returnsGeometryCollectionIfPartsAllFromSameFamily() {
    Geometry fixed = HELPER.collection(HELPER.box(0, 0, 10, 10), HELPER.box(20, 20, 30, 30));
    mockResult.set(fixed);
    assertThat(mockFixer.fix(INVALID_BOWTIE), sameInstance(fixed));
  }

  @Test
  public void doesNotReturnHetrogeneousGeometryCollections() {
    mockResult.set(HELPER.collection(HELPER.box(0, 0, 10, 10), HELPER.point(0, 0)));
    assertThat(mockFixer.fix(INVALID_BOWTIE), nullValue());
  }

  private CsvReader getInvalidCsv() throws Exception {
    CsvReader reader = new CsvReader(new InputStreamReader(
        GeometryFixerTest.class.getResourceAsStream("/nz/org/riskscape/engine/geo/invalid.csv"))
    );
    reader.readHeaders();
    reader.setCaptureRawRecord(true);
    return reader;
  }

  @Ignore
  @Test
  public void renderInvalidGeometries() throws Exception {
    // this isn't really a test at all. rather it is to output images of the invalid and fixed geometries
    // as image files so we can see what the fixes look like.
    // to use uncomment the ignore and run the test.
    Path outputDir = Files.createTempDirectory("invalid-geometries");
    System.out.format("Writing invalid geometries to %s%n", outputDir);

    SimpleFeatureTypeBuilder builder = new SimpleFeatureTypeBuilder();
    builder.setCRS(SRIDSet.WILDCARD_2D);
    builder.setName("invalid");
    builder.add("the_geom", Geometry.class);
    SimpleFeatureType featureType = builder.buildFeatureType();

    CsvReader reader = getInvalidCsv();
    while (reader.readRecord()) {
      String[] values = reader.getValues();
      String description = values[0].trim();
      String invalidWkt = values[1].trim();
      String fixedWkt = values[3].trim();

      if (!"".equals(fixedWkt)) {
        Geometry invalid = HELPER.fromWkt(invalidWkt);
        Geometry fixed = HELPER.fromWkt(fixedWkt);

        IsValidOp isValidOp = new IsValidOp(invalid);
        StringBuilder sb = new StringBuilder();
        sb.append(description);
        sb.append(OsUtils.LINE_SEPARATOR);
        sb.append("validation error: ").append(isValidOp.getValidationError());

        String dirName = description.replaceAll("[^a-zA-Z0-9]", " ").replaceAll("\\s+", "-");
        Path dir = Files.createTempDirectory(outputDir, dirName);

        Files.write(dir.resolve("readme.txt"), sb.toString().getBytes());
        saveGeomAsImage(invalid, "invalid", dir, featureType);
        Files.write(dir.resolve("invalid.wkt"), invalidWkt.getBytes());
        saveGeomAsImage(fixed, "fixed", dir, featureType);
        Files.write(dir.resolve("fixed.wkt"), fixedWkt.getBytes());
      }
    }
  }

  /**
   * Save the geometry as an image to <dir>/<name>.jpg
   */
  private void saveGeomAsImage(Geometry geom, String name, Path dir, SimpleFeatureType featureType) {
    MapContent map = new MapContent();
    map.setTitle("World");
    MapViewport vp = map.getViewport();
    vp.setCoordinateReferenceSystem(SRIDSet.WILDCARD_2D);

    map.addLayer(buildLayer(geom, featureType));
    saveImage(map, dir.resolve(name + ".jpg"), 800);
    map.dispose();
  }

  /**
   * Build a layer containing the geom.
   */
  private Layer buildLayer(Geometry geom, SimpleFeatureType featureType) {
    List<SimpleFeature> features = new ArrayList<>();

    SimpleFeatureBuilder featureBuilder = new SimpleFeatureBuilder(featureType);
    featureBuilder.add(geom);
    features.add(featureBuilder.buildFeature("1"));

    SimpleFeatureCollection collection = new ListFeatureCollection(featureType, features);

    StyleBuilder builder = new StyleBuilder();
    FeatureTypeStyle featureTypeStyle = builder.createFeatureTypeStyle(builder.createPolygonSymbolizer());
    Style style = builder.createStyle();
    style.setName("invalid");
    style.featureTypeStyles().add(featureTypeStyle);
    Layer layer = new FeatureLayer(collection, style);
    layer.setVisible(true);
    return layer;
  }

  private void saveImage(final MapContent map, final Path file, final int imageWidth) {

    GTRenderer renderer = new StreamingRenderer();
    renderer.setMapContent(map);

    Rectangle imageBounds = null;
    ReferencedEnvelope mapBounds = null;
    try {
      mapBounds = map.getMaxBounds();
      double heightToWidth = mapBounds.getSpan(1) / mapBounds.getSpan(0);
      imageBounds = new Rectangle(
          0, 0, imageWidth, (int) Math.round(imageWidth * heightToWidth));

      BufferedImage image = new BufferedImage(imageBounds.width, imageBounds.height, BufferedImage.TYPE_INT_RGB);

      Graphics2D gr = image.createGraphics();
      gr.setPaint(Color.WHITE);
      gr.fill(imageBounds);

      renderer.paint(gr, imageBounds, mapBounds);
      File fileToSave = file.toFile();
      ImageIO.write(image, "jpeg", fileToSave);

    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  private Geometry fixInvalid(Geometry geom) {
    assertFalse(geom.isValid());
    Geometry fixed = fixer.fix(geom);
    assertTrue(fixed.isValid());
    return fixed;
  }

}
