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

import static nz.org.riskscape.engine.Matchers.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import java.math.BigDecimal;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.geotools.coverage.grid.GridCoverage2D;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.Mockito;
import org.geotools.api.coverage.Coverage;
import org.geotools.api.coverage.grid.GridCoverage;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;

import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.coverage.GridTypedCoverage;
import nz.org.riskscape.engine.data.relation.RelationBookmarkParams;
import nz.org.riskscape.engine.data.relation.RelationBookmarkResolver;
import nz.org.riskscape.engine.query.TupleUtils;
import nz.org.riskscape.engine.raster.VectorToRaster;
import nz.org.riskscape.engine.relation.ListRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.relation.SpatialMetadata;
import nz.org.riskscape.engine.rl.DefaultOperators;
import nz.org.riskscape.engine.rl.RealizedExpression;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.ExpressionProblems;

public class RelationBookmarkResolverRasterizationTest extends ProjectTest {

  BindingContext context = project.newBindingContext();

  ImmutableMap.Builder<String, String> unparsed = ImmutableMap.<String, String>builder()
      .put("validate-geometry", "off")
      .put("skip-invalid", "false")
      .put("rasterize", "true");

  Struct struct;
  Relation relation;
  VectorToRaster mockV2r = mock(VectorToRaster.class);
  String expression = "value";

  @SuppressWarnings({ "unchecked", "rawtypes" })
  public class Subject extends RelationBookmarkResolver<RelationBookmarkParams> {

    public Subject(Engine engine) {
      super(engine);
      this.v2r = mockV2r;
    }

    @Override
    protected Map<String, String> getExtensionsToFormats() {
      return Collections.emptyMap();
    }

    @Override
    protected ResultOrProblems<Relation> createRawRelationFromBookmark(RelationBookmarkParams params) {

      return ResultOrProblems.of(relation);
    }
  };

  Subject subject = new Subject(engine);

  SpatialMetadata spatialMetadata;

  GridCoverage2D mockGrid = mock(GridCoverage2D.class);

  GeometryFactory gf = project.getSridSet().getGeometryFactory(project.getDefaultCrs());

  String scaleFactor = "1";

  private String gridSizeMetres;

  @Before
  public void setup() {
    when(mockGrid.getEnvelope()).thenReturn(new ReferencedEnvelope(project.getDefaultCrs()));
    project.getFunctionSet().insertFirst(DefaultOperators.INSTANCE);
  }

  @Test
  public void canAskForRasterizationOfInteger() throws Exception {
    struct = Struct.of("value", Types.INTEGER, "geom", Types.GEOMETRY);
    spatialMetadata = new SpatialMetadata(project.getDefaultCrs(), TupleUtils.findRequiredGeometryMember(struct));
    List<Tuple> tuples = Arrays.asList(
        Tuple.ofValues(struct, 1, gf.createPoint(new Coordinate(0, 0))),
        Tuple.ofValues(struct, 1, gf.createPoint(new Coordinate(10, 10)))
    );
    relation = new ListRelation(tuples).withSpatialMetadata(spatialMetadata);
    ResolvedBookmark resolved = resolve();

    ReferencedEnvelope bounds = new ReferencedEnvelope(0, 10, 0, 10, project.getDefaultCrs());
    when(mockV2r.convert(any(), any(), anyDouble(), any(), any())).thenReturn(mockGrid);

    assertEquals(TypedCoverage.class, resolved.getDataType());
    GridCoverage gridCov = getResolvedCoverage(resolved.getData(TypedCoverage.class).get());

    verify(mockV2r).convert(
        same(relation),
        Mockito.any(RealizedExpression.class),
        eq(1.0D),
        eq(bounds),
        eq("bookmark"));

    assertSame(mockGrid, gridCov);
  }

  private ResolvedBookmark resolve() {
    return subject.newResolved(subject.bindAndValidate(bookmark(), executionContext));
  }

  @Test
  public void canAskForRasterizationOfDecimal() throws Exception {
    struct = Struct.of("value", Types.DECIMAL, "geom", Types.GEOMETRY);
    spatialMetadata = new SpatialMetadata(project.getDefaultCrs(), TupleUtils.findRequiredGeometryMember(struct));
    List<Tuple> tuples = Arrays.asList(
        Tuple.ofValues(struct, new BigDecimal(1), gf.createPoint(new Coordinate(0, 0))),
        Tuple.ofValues(struct, new BigDecimal(55), gf.createPoint(new Coordinate(10, 10)))
    );
    relation = new ListRelation(tuples).withSpatialMetadata(spatialMetadata);
    ResolvedBookmark resolved = resolve();

    ReferencedEnvelope bounds = new ReferencedEnvelope(0, 10, 0, 10, project.getDefaultCrs());
    when(mockV2r.convert(any(), any(), anyDouble(), any(), any())).thenReturn(mockGrid);

    assertEquals(TypedCoverage.class, resolved.getDataType());
    GridCoverage gridCov = getResolvedCoverage(resolved.getData(TypedCoverage.class).get());

    verify(mockV2r).convert(
        same(relation),
        Mockito.any(RealizedExpression.class),
        eq(1.0D),
        eq(bounds),
        eq("bookmark"));

    assertSame(mockGrid, gridCov);
  }

  @Test
  public void canAskForRasterizationOfFloating() throws Exception {
    struct = Struct.of("value", Types.FLOATING, "geom", Types.GEOMETRY);
    spatialMetadata = new SpatialMetadata(project.getDefaultCrs(), TupleUtils.findRequiredGeometryMember(struct));
    List<Tuple> tuples = Arrays.asList(
        Tuple.ofValues(struct, new BigDecimal(1), gf.createPoint(new Coordinate(0, 0))),
        Tuple.ofValues(struct, new BigDecimal(55), gf.createPoint(new Coordinate(10, 10)))
    );
    relation = new ListRelation(tuples).withSpatialMetadata(spatialMetadata);
    ResolvedBookmark resolved = resolve();

    ReferencedEnvelope bounds = new ReferencedEnvelope(0, 10, 0, 10, project.getDefaultCrs());
    when(mockV2r.convert(any(), any(), anyDouble(), any(), any())).thenReturn(mockGrid);

    assertEquals(TypedCoverage.class, resolved.getDataType());
    GridCoverage gridCov = getResolvedCoverage(resolved.getData(TypedCoverage.class).get());

    verify(mockV2r).convert(
        same(relation),
        Mockito.any(RealizedExpression.class),
        eq(1.0D),
        eq(bounds),
        eq("bookmark"));

    assertSame(mockGrid, gridCov);
  }

  @Test
  public void canAskForRasterizationOfArbitraryExpression() throws Exception {
    struct = Struct.of("lhs", Types.INTEGER, "rhs", Types.INTEGER, "geom", Types.GEOMETRY);
    expression = "lhs + rhs";
    spatialMetadata = new SpatialMetadata(project.getDefaultCrs(), TupleUtils.findRequiredGeometryMember(struct));
    List<Tuple> tuples = Arrays.asList(
        Tuple.ofValues(struct, 1, 1, gf.createPoint(new Coordinate(0, 0))),
        Tuple.ofValues(struct, 2, 2, gf.createPoint(new Coordinate(10, 10)))
    );
    relation = new ListRelation(tuples).withSpatialMetadata(spatialMetadata);
    ResolvedBookmark resolved = resolve();

    ReferencedEnvelope bounds = new ReferencedEnvelope(0, 10, 0, 10, project.getDefaultCrs());
    when(mockV2r.convert(any(), any(), anyDouble(), any(), any())).thenReturn(mockGrid);

    assertEquals(TypedCoverage.class, resolved.getDataType());
    GridCoverage gridCov = getResolvedCoverage(resolved.getData(TypedCoverage.class).get());
    Expression expr = expressionParser.parse(expression);
    verify(mockV2r).convert(
        same(relation),
        Mockito.any(RealizedExpression.class),
        eq(1.0D),
        eq(bounds),
        eq("bookmark"));

    assertSame(mockGrid, gridCov);
  }

  @Test
  public void canSpecifyGridSizeInsteadOfScaleFactor() throws Exception {
    struct = Struct.of("value", Types.INTEGER, "geom", Types.GEOMETRY);
    scaleFactor = null;
    // We want a gridSize of 10 degrees(in CRS units), so we mutltiply by:
    // 60 (nautical miles per degree)
    // 1852 (metres per nautical mile)
    gridSizeMetres = String.valueOf(10 * 60 * 1852);
    spatialMetadata = new SpatialMetadata(project.getDefaultCrs(), TupleUtils.findRequiredGeometryMember(struct));
    List<Tuple> tuples = Arrays.asList(
        Tuple.ofValues(struct, 1, gf.createPoint(new Coordinate(0, 0))),
        Tuple.ofValues(struct, 1, gf.createPoint(new Coordinate(10, 10)))
    );
    relation = new ListRelation(tuples).withSpatialMetadata(spatialMetadata);
    ResolvedBookmark resolved = resolve();

    ReferencedEnvelope bounds = new ReferencedEnvelope(0, 10, 0, 10, project.getDefaultCrs());
    when(mockV2r.convert(any(), any(), anyDouble(), any(), any())).thenReturn(mockGrid);

    assertEquals(TypedCoverage.class, resolved.getDataType());
    GridCoverage gridCov = getResolvedCoverage(resolved.getData(TypedCoverage.class).get());

    verify(mockV2r).convert(
        same(relation),
        Mockito.any(RealizedExpression.class),
        eq(0.1D),
        eq(bounds),
        eq("bookmark"));

    assertSame(mockGrid, gridCov);
  }

  @Test
  public void willFailIfMissingAnExpression() throws Exception {
    expression = null;
    ResolvedBookmark resolved = resolve();
    assertThat(resolved.validate(), contains(isProblem(Severity.ERROR, containsString("'rasterize-expression' is "
        + "required when 'rasterize' is set to true"))));
  }

  @Test
  public void willFailIfMissingAScaleAndGridSize() throws Exception {
    scaleFactor = null;
    ResolvedBookmark resolved = resolve();
    assertThat(resolved.validate(), contains(isProblem(Severity.ERROR, containsString("'rasterize-scale-factor' or "
        + "'rasterize-grid-size' is required when 'rasterize' is set to true"))));

  }

  @Test
  public void willFailIfScaleIsNotANumber() throws Exception {
    scaleFactor = "foo";
    ResolvedBookmark resolved = resolve();
    assertThat(
        resolved.validate(),
        contains(equalIgnoringChildren(
            ParamProblems.get().bindingError(subject.getParameterSet().get("rasterize-scale-factor"))
        ))
    );
  }



  @Test
  public void willFailIfScaleIsLessThanZero() throws Exception {
    scaleFactor = "-1";
    ResolvedBookmark resolved = resolve();
    assertThat(resolved.validate(), contains(isProblem(Severity.ERROR, containsString("'rasterize-scale-factor'"
        + " must be a non-zero positive number"))));
  }

  @Test
  public void willFailIfGridSizeNotANumber() throws Exception {
    scaleFactor = null;
    gridSizeMetres = "large";

    ResolvedBookmark resolved = resolve();
    assertThat(
        resolved.validate(),
        hasItem(equalIgnoringChildren(
            ParamProblems.get().bindingError(subject.getParameterSet().get("rasterize-grid-size"))
        ))
    );
  }

  @Test
  public void willFailIfGridSizeIsLessEqThanZero() throws Exception {
    scaleFactor = null;
    gridSizeMetres = "0";
    ResolvedBookmark resolved = resolve();
    assertThat(resolved.validate(), contains(isProblem(Severity.ERROR, containsString("'rasterize-grid-size' must be "
        + "a non-zero positive number"))));

  }

  @Test
  public void willFailIfGridSizeAndScaleSupplied() throws Exception {
    scaleFactor = "1";
    gridSizeMetres = "1";
    ResolvedBookmark resolved = resolve();
    assertThat(resolved.validate(), contains(isProblem(Severity.ERROR, containsString("Only one of "
        + "'rasterize-grid-size' and 'rasterize-scale-factor' can be specified"))));
  }

  @Test
  public void willFailIfExpressionIsBad() throws Exception {
    expression = "(cool) -- story bro";
    ResolvedBookmark resolved = resolve();
    assertThat(
        resolved.validate(),
        contains(equalIgnoringChildren(
            ParamProblems.get().bindingError(subject.getParameterSet().get("rasterize-expression"))
        ))
    );
  }


  @Test
  public void willFailIfExpressionsTypeIsNotANumber() throws Exception {
    struct = Struct.of("value", Types.TEXT);
    expression = "value";

    relation = new ListRelation(struct);
    ResolvedBookmark resolved = resolve();
    ResultOrProblems<TypedCoverage> gc = resolved.getData(TypedCoverage.class);
    assertThat(gc.getProblems(), contains(isProblem(Severity.ERROR, containsString("Expression 'value' does not result "
        + "in a numeric type"))));
  }

  @Test
  public void attemptingToRasterizeNonSpatialThingFails() throws Exception {
    struct = Struct.of("value", Types.INTEGER);
    expression = "value";

    relation = new ListRelation(struct);
    ResolvedBookmark resolved = resolve();
    ResultOrProblems<TypedCoverage> gc = resolved.getData(TypedCoverage.class);
    assertThat(gc.getProblems(), contains(isProblem(Severity.ERROR,
        containsString("spatial metadata - can not convert to a raster"))));
  }

  @Test
  public void willFailIfExpressionsRefersToMissingAttribute() throws Exception {
    struct = Struct.of("value", Types.TEXT);
    expression = "vale";

    relation = new ListRelation(struct);
    ResolvedBookmark resolved = resolve();
    ResultOrProblems<TypedCoverage> gc = resolved.getData(TypedCoverage.class);
    assertThat(gc.getProblems(), hasItems(
        ExpressionProblems.get().noSuchStructMember("vale", Arrays.asList("value"))
    ));
  }

  protected Bookmark bookmark() {

    if (expression != null) {
      unparsed.put("rasterize-expression", expression);
    }

    if (scaleFactor != null) {
      unparsed.put("rasterize-scale-factor", scaleFactor);
    }

    if (gridSizeMetres != null) {
      unparsed.put("rasterize-grid-size", gridSizeMetres);
    }

    return Bookmark.builder()
        .id("bookmark")
        .location(URI.create("http://riskscape.com"))
        .build().addUnparsed(Maps.transformValues(unparsed.build(), Collections::singletonList));
  }

  private GridCoverage getResolvedCoverage(TypedCoverage typedCoverage) {
    Coverage coverage = ((GridTypedCoverage)typedCoverage).getCoverage();
    return (GridCoverage)coverage;
  }
}
