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

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

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.hamcrest.Matcher;
import org.junit.Test;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;

import com.google.common.base.Strings;

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.RowMatchers;
import nz.org.riskscape.engine.util.Pair;

public class NonPointModellingTest extends BaseModelRunCommandTest {

  WKTReader wktReader = new WKTReader();

  @Test
  public void lossAtExposureCentroidMightNotBeGreatResult() throws Exception {
    // this is a sanity check showing what happens if we just model at exposure centroid
    // total loss in this case, because that is where the hazard is.
    runCommand.modelId = "crop";
    runCommand.runnerOptions.format = "csv";
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "hazard.geom", "hazard.depth", "consequence");

    assertThat(
        rows,
        contains(
            contains(
                is("POLYGON ((1512000 5172000, 1512000 5173000, 1513000 5173000, 1513000 5172000, 1512000 5172000))"),
                is("maize"),
                is("100000.0"), // exposure value
                is("POLYGON ((1512000 5172600, 1512000 5172325, 1513000 5172325, 1513000 5172600, 1512000 5172600))"),
                is("20.0"), // hazard depth
                is("100000.0") // total loss
            )
        )
    );
  }

  @Test
  public void canSegmentPolygonsAndScaleAttributes() throws Exception {
    runCommand.modelId = "crop-segment200";
    runCommand.runnerOptions.format = "csv";
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "exposure.segmentID", "hazard.geom", "hazard.depth", "consequence");

    assertThat(rows, hasSize(25));
    //All segmented values should be the same scaled exposure value
    for (List<String> row : rows) {
      assertThat(row.get(2), is("4000.0"));
    }

    Map<String, Integer> consequences = rows.stream()
        .collect(Collectors.toMap(row -> row.get(6), row -> 1, (a, b) -> a + b));
    assertThat(consequences, hasEntry("", 20));
    assertThat(consequences, hasEntry("4000.0", 5));

    Double consequence = rows.stream()
        .map(row -> row.get(6))
        .collect(Collectors.summingDouble(c -> Strings.isNullOrEmpty(c) ? 0D : Double.valueOf(c)));

    // 20% crop loss.
    assertThat(consequence, is(20000D));
  }

  @Test
  public void canSegmentPolygonsAllIntersectionsAggregateConsequenceWithScaleByExposedRatio() throws Exception {
    runCommand.modelId = "crop-segment200";
    runCommand.runnerOptions.format = "csv";
    runCommand.parameters = Arrays.asList(
        "sample.hazards-by=all_intersections",
        "analysis.scale-losses=TRUE",
        "analysis.aggregate-consequences=true",
        "analysis.aggregate-consequences-function=mean"
    );
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "exposure.segmentID", "hazard", "exposed_ratio",
        "consequence", "raw_consequence");

    assertThat(rows, hasSize(25));
    //All segmented values should be the same scaled exposure value
    for (List<String> row : rows) {
      assertThat(row.get(2), is("4000.0"));
    }

    Map<String, Integer> consequences = rows.stream()
        .collect(Collectors.toMap(row -> row.get(6), row -> 1, (a, b) -> a + b));
    assertThat(consequences, hasEntry("", 15));
    // The different values here are because each segment is multiplied by it's individual exposure_ratio
    assertThat(consequences, hasEntry("1500.0", 5));
    assertThat(consequences, hasEntry("4000.0", 5));

    Double consequence = rows.stream()
        .map(row -> row.get(6))
        .collect(Collectors.summingDouble(c -> Strings.isNullOrEmpty(c) ? 0D : Double.valueOf(c)));

    // 27.6% crop loss, less then 1% away from actual flood coverage
    assertThat(consequence, is(27500D));
  }

  @Test
  public void canSegmentPolygonsAllIntersectionsAggregateConsequenceByPercentiles() throws Exception {
    runCommand.modelId = "crop-all-intersections";
    runCommand.runnerOptions.format = "csv";
    runCommand.parameters = Arrays.asList(
        "input-hazards.layer=flood2",
        "sample.hazards-by=all_intersections",
        "analysis.scale-losses=TRUE",
        "analysis.aggregate-consequences=true",
        "analysis.aggregate-consequences-function=percentiles(value, [10, 50, 100])"
    );
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "hazard", "exposed_ratio",
        "consequence.P10", "consequence.P50", "consequence.P100", "raw_consequence");

    assertThat(rows, hasSize(1));
    List<String> row = rows.iterator().next();

    assertThat(Double.parseDouble(row.get(4)), closeTo(0.295D, 0.000001)); // exposed ratio, 0.01 + 0.01 + 0.275
    assertThat(row.get(5), is("0.0"));                  // P10
    assertThat(row.get(6), is("1000.0"));               // P50
    assertThat(row.get(7), is("27500.000000000004"));   // P100
  }

  @Test
  public void canSegmentPolygonsAndScaleAttributesAt300Metres() throws Exception {
    runCommand.modelId = "crop-segment200";
    runCommand.parameters = Arrays.asList("input-exposures.cut-distance=300");
    runCommand.runnerOptions.format = "csv";
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "exposure.segmentID", "hazard.geom", "hazard.depth", "consequence");

    List<Pair<Geometry, String>> geomAndValue = rows.stream()
        .map(row -> Pair.of(fromWKT(row.get(0)), row.get(2)))
        .collect(Collectors.toList());

    assertThat(geomAndValue, hasSize(16));

    // check that segments have had the value scaled according to their new size
    assertThat(geomAndValue, containsInAnyOrder(
        geomAndValueMatcher(
            "POLYGON ((1512300 5172900, 1512600 5172900, 1512600 5172600, 1512300 5172600, 1512300 5172900))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512600 5172900, 1512300 5172900, 1512300 5173000, 1512600 5173000, 1512600 5172900))",
            "3000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512600 5172900, 1512900 5172900, 1512900 5172600, 1512600 5172600, 1512600 5172900))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512300 5172900, 1512000 5172900, 1512000 5173000, 1512300 5173000, 1512300 5172900))",
            "3000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512300 5172900, 1512300 5172600, 1512000 5172600, 1512000 5172900, 1512300 5172900))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512900 5172900, 1512900 5173000, 1513000 5173000, 1513000 5172900, 1512900 5172900))",
            "1000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512900 5172900, 1512600 5172900, 1512600 5173000, 1512900 5173000, 1512900 5172900))",
            "3000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512600 5172000, 1512600 5172300, 1512900 5172300, 1512900 5172000, 1512600 5172000))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512300 5172000, 1512300 5172300, 1512600 5172300, 1512600 5172000, 1512300 5172000))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512300 5172600, 1512300 5172300, 1512000 5172300, 1512000 5172600, 1512300 5172600))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512000 5172000, 1512000 5172300, 1512300 5172300, 1512300 5172000, 1512000 5172000))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512600 5172600, 1512900 5172600, 1512900 5172300, 1512600 5172300, 1512600 5172600))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512900 5172300, 1512900 5172600, 1513000 5172600, 1513000 5172300, 1512900 5172300))",
            "3000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512300 5172600, 1512600 5172600, 1512600 5172300, 1512300 5172300, 1512300 5172600))",
            "9000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512900 5172000, 1512900 5172300, 1513000 5172300, 1513000 5172000, 1512900 5172000))",
            "3000.0"),
        geomAndValueMatcher(
            "POLYGON ((1512900 5172600, 1512900 5172900, 1513000 5172900, 1513000 5172600, 1512900 5172600))",
            "3000.0")
    ));

    Double consequence = rows.stream()
        .map(row -> row.get(6))
        .collect(Collectors.summingDouble(c -> Strings.isNullOrEmpty(c) ? 0D : Double.valueOf(c)));

    // 20% crop loss.
    assertThat(consequence, is(30000D));
  }

  @Test
  public void canSegmentPolygonsAndScaleAttributesWithReprojection() throws Exception {
    runCommand.modelId = "crop-segment200";
    runCommand.parameters = Arrays.asList("input-exposures.layer=crop-4326");
    runCommand.runnerOptions.format = "csv";
    runCommand.run();
    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.the_geom", "exposure.crop",
        "exposure.value","exposure.segmentID", "hazard.geom", "hazard.depth", "consequence");

    // reprojecting from lat/long changes the segmenting quite a bit. In this case we are getting
    // half tiles around the perimeter, whereas in the NZ case there are only whole tiles. This will
    // be caused by differences in the re-projected size.
    assertThat(rows, hasSize(36));

    // that also changes how many tiles have their centroid in the hazard and the sizes. So lets
    // just look at the total consequence
    Double consequence = rows.stream()
        .map(row -> row.get(6))
        .collect(Collectors.summingDouble(c -> Strings.isNullOrEmpty(c) ? 0D : Double.valueOf(c)));

    // 20% loss versus actual flood coverage of 22.5%. The difference is explained by the different
    // tile layout. So maybe we should make some comment like same data in differenct projections
    // may not give the same answers
    assertThat(consequence, closeTo(20006.33D, 0.01D));
  }

  @Test
  public void canCutByAnAreaLayerBeforeSamplingAllIntersections() throws Exception {
    runCommand.modelId = "crops-cut-by-area-then-all-intersections";
    runCommand.runnerOptions.format = "csv";
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv",
        "exposure.geom", "exposure.crop", "exposure.value", "exposure.area_name",
        "exposure.segmentID", "hazard", "exposed_ratio",
        "consequence", "raw_consequence");

    assertThat(rows, hasSize(4));

    // the original value was 100,000, we check that the consequences effectively got scaled to some reasonable and not
    // overly-accurate amount
    assertThat(rows,
      hasItems(
        hasItems(equalTo("Gettysburg"), RowMatchers.numberWithin(32800, 32900)),
        hasItems(equalTo("Ypres"), RowMatchers.numberWithin(43700, 43800)),
        // rorke's drift was missing a value
        hasItems(equalTo("Rorke's Drift")),
        // a non-intersecting chunk had a sample
        hasItems(RowMatchers.numberWithin(11800, 11900))
      )
    );
  }

  @Test
  public void samplingAllIntersectionsProducesExposureRatio() throws Exception {
    runCommand.modelId = "crop-all-intersections";
    runCommand.runnerOptions.format = "csv";
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "hazard", "exposed_ratio", "consequence");

    assertThat(rows, contains(
        rowMatches(
            Pair.of(4, is("0.275")),      // exposed ratio
            Pair.of(5, is("100000.0"))    // 100% loss (no scaling happening here
        )
    ));
  }

  @Test
  public void samplingAllIntersectionsAggregateConsequenceAndScaleByExposedRatio() throws Exception {
    runCommand.modelId = "crop-all-intersections";
    runCommand.runnerOptions.format = "csv";
    runCommand.parameters = Arrays.asList(
        "analysis.scale-losses=y",
        "analysis.aggregate-consequences = true",
        "analysis.aggregate-consequences-function = sum"
    );
    runCommand.run();

    Set<List<String>> rows = openCsvUniqueData("event-impact.csv", "exposure.geom", "exposure.crop", "exposure.value",
        "hazard", "exposed_ratio",
        "consequence", "raw_consequence");

    assertThat(rows, contains(
        rowMatches(
            Pair.of(4, is("0.275")),                // exposed ratio
            Pair.of(6, is("[100000.0]")),           // raw consequence
            Pair.of(5, is("27500.000000000004"))    // scaled loss
        )
    ));
  }

  @Test
  public void samplingAllIntersectionsAggregateConsequenceAndSaveRawResults() throws Exception {
    runCommand.modelId = "crop-segment200";
    runCommand.parameters = Arrays.asList(
        "input-exposures.save-raw-results=true",
        "sample.hazards-by=all_intersections",
        "analysis.scale-losses=TRUE",
        "analysis.aggregate-consequences=true",
        "analysis.aggregate-consequences-function=mean"
    );
    runCommand.run();

    // we just check that the shapefile gets produced
    assertThat(getTempDirectory().resolve("exposure-geoprocessed.shp").toFile(), anExistingFile());
  }

  public Matcher<Pair<Geometry, String>> geomAndValueMatcher(String geom, String value) {
    return allOf(
        hasProperty("left", GeometryMatchers.isGeometry(fromWKT(geom))),
        hasProperty("right", is(value))
    );
  }

  Geometry fromWKT(String wkt) {
    try {
      return wktReader.read(wkt);
    } catch (ParseException e) {
      throw new RuntimeException(e);
    }
  }
}
