/*
 * 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.Assert.*;
import static nz.org.riskscape.engine.Matchers.*;

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

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.geotools.referencing.CRS;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;

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

import nz.org.riskscape.engine.CrsHelper;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.data.relation.RelationBookmarkParams;
import nz.org.riskscape.engine.data.relation.RelationBookmarkResolver;
import nz.org.riskscape.engine.function.StringFunctions;
import nz.org.riskscape.engine.projection.TypeProjection;
import nz.org.riskscape.engine.relation.InvalidTupleException;
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.GeometryFunctions;
import nz.org.riskscape.engine.rl.MathsFunctions;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.ExpressionProblems;

public class RelationBookmarkResolverTest extends ProjectTest implements CrsHelper {

  Struct sourceType = Struct.of("foo", Types.TEXT, "bar", Types.TEXT);
  ListRelation sourceRelation = new ListRelation(sourceType, Tuple.ofValues(sourceType, "foo", "bar"));

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

    Subject(Engine engine) {
      super(engine);
    }

    @Override
    protected ResultOrProblems<Relation> createRawRelationFromBookmark(RelationBookmarkParams params) {
      return ResultOrProblems.of(sourceRelation);
    }

    @Override
    protected Map<String, String> getExtensionsToFormats() {
      return ImmutableMap.of("foo", "bar");
    }
  }

  Subject subject;
  Bookmark bookmark;

  Struct fooType = Struct.of("foo", Types.TEXT);
  Struct geomType = Struct.of("desc", Types.TEXT, "geometry", Types.GEOMETRY);

  @Before
  public void setup() {
    project.getFunctionSet().insertFirst(DefaultOperators.INSTANCE);
    GeometryFunctions geometryFunctions = new GeometryFunctions(engine);
    project.getFunctionSet().addAll(geometryFunctions.getPredicates());
    project.getFunctionSet().addAll(geometryFunctions.getFunctions());
    project.getFunctionSet().addAll(MathsFunctions.FUNCTIONS);
    project.getFunctionSet().addAll(StringFunctions.FUNCTIONS);

    project.getTypeSet().add("foo-type", fooType);
    project.getTypeSet().add("geom-type", geomType);
    subject = new Subject(engine);
  }

  @Test
  public void canCreateABookmarkWithoutAType() throws Exception {
    bookmark(ImmutableMap.of("skip-invalid", "false"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    assertSame(sourceRelation, relation);
  }

  @Test
  public void canCreateABookmarkWithAType() throws Exception {
    bookmark(ImmutableMap.of("type", "foo-type"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    if (relation instanceof ListRelation) {
      assertSame(project.getTypeSet().getRequired("foo-type").getUnderlyingType(), relation.getType());
    } else {
      fail("Expected relation to be a of projected type");
    }
  }

  @Test
  public void canCreateABookmarkAndSetAttributes() throws Exception {
    bookmark(ImmutableMap.of("set-attribute.bar", "str_length(foo)"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    Struct expectedResult = Struct.of("foo", Types.TEXT, "bar", Types.INTEGER);
    assertThat(relation.getType(), is(expectedResult));
    assertThat(relation.iterator().next(), is(Tuple.ofValues(expectedResult, "foo", 3L)));
  }

  @Test
  public void anErrorIsProducedIfSetAttributesAHaveABadExpression() throws Exception {
    bookmark(ImmutableMap.of("set-attribute.bar", ""));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(
      resolved.validate(),
      contains(
        ParamProblems.get().bindingError(
          subject.getParameterSet().get("set-attribute"),
          Problems.foundWith("bar", ExpressionProblems.get().emptyStringNotValid())
        )
      )
    );
  }

  @Test
  public void anErrorIsProducedIfTypeAndSetAttributesSpecified() throws Exception {
    bookmark(ImmutableMap.of("type", "foo-type", "set-attribute.bar", "str_length(foo)"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(
      resolved.validate(),
      contains(
        allOf(
          hasAncestorProblem(is(Problems.get(BookmarkProblems.class).setAttributesNotAllowedWithType()))
        )
      )
    );
  }

  @Test
  public void canCreateABookmarkWithATypeAndMappedAttributes() throws Exception {
    bookmark(ImmutableMap.of("type", "foo-type", "map-attribute.foo", "bar"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    assertSame(project.getTypeSet().getRequired("foo-type").getUnderlyingType(), relation.getType());
    Tuple mapped = relation.iterator().next();
    assertEquals("bar", mapped.fetch("foo"));
  }

  @Test
  public void anErrorIsProducedIfMappedAttributesHaveABadExpression() throws Exception {
    bookmark(ImmutableMap.of("type", "foo-type", "map-attribute.foo", ""));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();

    assertThat(
      resolved.validate(),
      contains(
        allOf(
          equalIgnoringChildren(
              ParamProblems.get().bindingError(subject.getParameterSet().get("map-attribute"),
              Problems.foundWith("foo", ExpressionProblems.get().emptyStringNotValid()))
          )
        )
      )
    );
  }

  @Test
  public void aWarningIsProducedIfMappedAttributesGivenWithoutAType() throws Exception {
    bookmark(ImmutableMap.of("map-attribute.foo", "bar"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();

      assertTrue(resolved.validate().get(0).getMessage()
          .startsWith("Attribute mappings were given, but no type specified"));
  }

  @Test
  public void canCreateARelationThatSkipsInvalidTuples() {
    sourceRelation = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "rad", "bar"),
        Tuple.ofValues(sourceType, null, "bar"),
        Tuple.ofValues(sourceType, "tad", "bar"));

    bookmark(ImmutableMap.of("skip-invalid", "true", "type", "foo-type"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    assertSame(project.getTypeSet().getRequired("foo-type").getUnderlyingType(), relation.getType());

    List<Tuple> collected = relation.stream().collect(Collectors.toList());
    assertEquals(2, collected.size());
    assertEquals("rad", collected.get(0).fetch("foo"));
    assertEquals("tad", collected.get(1).fetch("foo"));
    assertThat(sunkProblems, contains(equalIgnoringChildren(
        InputDataProblems.get().invalidTupleSkipped()
    )));
    assertThat(render(sunkProblems.get(0)), containsString(
        "Failed to coerce 'null' for foo[Text] from {foo=null, bar=bar} - "
        + "null is not a valid value for type Text"));

    // skip invalid is the default, so lets try again without setting it
    sunkProblems.clear();
    bookmark(ImmutableMap.of("type", "foo-type"));
    resolved = subject.resolve(bookmark, executionContext).get();
    relation = resolved.getData(Relation.class).get();

    assertSame(project.getTypeSet().getRequired("foo-type").getUnderlyingType(), relation.getType());

    collected = relation.stream().collect(Collectors.toList());
    assertEquals(2, collected.size());
    assertEquals("rad", collected.get(0).fetch("foo"));
    assertEquals("tad", collected.get(1).fetch("foo"));
    assertThat(sunkProblems, contains(equalIgnoringChildren(
        InputDataProblems.get().invalidTupleSkipped()
    )));
    assertThat(render(sunkProblems.get(0)), containsString(
        "Failed to coerce 'null' for foo[Text] from {foo=null, bar=bar} - "
                + "null is not a valid value for type Text"));
  }

  @Test
  public void canCreateARelationThatDoesNoInvalidTuples() {
    sourceRelation = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "rad", "bar"),
        Tuple.ofValues(sourceType, null, "bar"),
        Tuple.ofValues(sourceType, "tad", "bar"));

    bookmark(ImmutableMap.of("skip-invalid", "false", "type", "foo-type"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    assertSame(project.getTypeSet().getRequired("foo-type").getUnderlyingType(), relation.getType());

    assertThrows(InvalidTupleException.class, () -> relation.stream().collect(Collectors.toList()));
  }

  @Test
  public void canCreateARelationThatAssignsGeometryFromAnExpression() throws Exception {
    sourceType = Struct.of("desc", Types.TEXT, "x", Types.TEXT, "y", Types.TEXT);
    sourceRelation = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "foo", "1", "2"));

    String crsName = "EPSG:4326";
    CoordinateReferenceSystem expected = CRS.decode(crsName);

    int nztmSrid = project.getSridSet().get(nzMapGrid());

    bookmark(ImmutableMap.of("map-attribute.geometry", "create_point(x, y)", "type", "geom-type", "crs-name", crsName));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    assertEquals(relation.getSpatialMetadata().get().getCrs(), expected);

    List<Tuple> collected = relation.stream().collect(Collectors.toList());
    Tuple zero = collected.get(0);

    Point point = zero.fetch("geometry");
    assertNotEquals(nztmSrid, point.getSRID());
    CoordinateReferenceSystem crs = project.getSridSet().get(point.getSRID());
    assertEquals(expected, crs);
  }

  @Test
  public void canCreateARelationThatSkipsTuplesWhereExpressionFails() throws Exception {
    sourceType = Struct.of("desc", Types.TEXT, "x", Types.TEXT, "y", Types.TEXT);
    sourceRelation = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "foo", "1", ""));

    String crsName = "EPSG:4326";

    bookmark(ImmutableMap.of("map-attribute.geometry", "create_point(x, y)", "type", "geom-type", "crs-name", crsName,
        "skip-invalid", "true"));

    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();

    List<Tuple> collected = relation.stream().collect(Collectors.toList());

    assertThat(collected, hasSize(0));

    assertThat(sunkProblems, contains(equalIgnoringChildren(
        InputDataProblems.get().invalidTupleSkipped()
    )));
    assertThat(render(sunkProblems.get(0)), allOf(
        containsString("Failed to evaluate expression "
        + "'create_point(x, y)' against {desc=foo, x=1, y=} for attribute geometry[Point]"),
        containsString("Failed to parse number from ''")
    ));
  }


  @Test
  public void canSetCrsViaAPropertyToReplaceAlreadyAssignedGeometryCrs() throws Exception {
    sourceType = geomType;
    CoordinateReferenceSystem longLat = longLat();
    project.getSridSet().get(longLat);
    GeometryFactory latLongFactory = project.getSridSet().getGeometryFactory(longLat);
    SpatialMetadata spatialMetadata = new SpatialMetadata(longLat(), sourceType.getEntry("geometry"));
    sourceRelation = new ListRelation(sourceType, Arrays.asList(
          Tuple.ofValues(sourceType, "foo", latLongFactory.createPoint(new Coordinate(1, 2)))
        ),
        spatialMetadata);

    String crsName = "EPSG:4326";
    CoordinateReferenceSystem expected = CRS.decode(crsName);

    bookmark(ImmutableMap.of("crs-name", crsName));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    Relation relation = resolved.getData(Relation.class).get();
    assertEquals(relation.getSpatialMetadata().get().getCrs(), expected);
    Tuple tuple = relation.iterator().next();
    Geometry geometry = tuple.fetch("geometry");
    assertEquals(project.getSridSet().get(expected), geometry.getSRID());
  }

  @Test
  public void canPreventFlippingOfAxisOrder() throws Exception {
    sourceType = geomType;
    CoordinateReferenceSystem longLat = longLat();
    project.getSridSet().get(longLat);
    GeometryFactory latLongFactory = project.getSridSet().getGeometryFactory(longLat);
    SpatialMetadata spatialMetadata = new SpatialMetadata(longLat(), sourceType.getEntry("geometry"));
    sourceRelation = new ListRelation(sourceType, Arrays.asList(
          Tuple.ofValues(sourceType, "foo", latLongFactory.createPoint(new Coordinate(1, 2)))
        ),
        spatialMetadata);

    String crsName = "EPSG:4326";

    bookmark(ImmutableMap.of("crs-name", crsName, "crs-longitude-first", "false"));
    ResolvedBookmark resolvedYX = subject.resolve(bookmark, executionContext).get();

    bookmark(ImmutableMap.of("crs-name", crsName, "crs-longitude-first", "true"));
    ResolvedBookmark resolvedXY = subject.resolve(bookmark, executionContext).get();

    CoordinateReferenceSystem llYX = resolvedXY.getData(Relation.class).get().getSpatialMetadata().get().getCrs();
    CoordinateReferenceSystem llXY = resolvedYX.getData(Relation.class).get().getSpatialMetadata().get().getCrs();


    assertNotEquals(llYX.getCoordinateSystem().getAxis(0), llXY.getCoordinateSystem().getAxis(0));
  }

  @Test
  public void badExpressionsCauseProblem() throws Exception {
    bookmark(ImmutableMap.of("map-attribute.foo", "baz", "type", "foo-type"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(resolved.getData(Relation.class).getProblems(), contains(
        isProblem(Severity.ERROR, TypeProjection.ProblemCodes.BAD_MAPPING)));
  }

  @Test
  public void missingAttributeWithTypeGivesAnErrorMessage() throws Exception {
    sourceRelation = new ListRelation(Struct.of("mystery", Types.TEXT));

    bookmark(ImmutableMap.of("type", "foo-type"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(resolved.getData(Relation.class).getProblems(), contains(
        isProblem(Severity.ERROR, TypeProjection.ProblemCodes.MISSING_MEMBER)));
  }

  @Test
  public void canApplyABoundingBoxToASpatialRelation() throws Exception {
    GeometryFactory geometryFactory = project.getSridSet().getGeometryFactory(project.getDefaultCrs());
    Struct type = Struct.of("geom", Types.GEOMETRY);
    sourceRelation = new ListRelation(
        Tuple.ofValues(type, geometryFactory.createPoint(new Coordinate(1, 1))),
        Tuple.ofValues(type, geometryFactory.createPoint(new Coordinate(8, 8)))
    );

    sourceRelation = sourceRelation.inferSpatialMetadata(project);

    bookmark(ImmutableMap.of("filter", "bbox(geom, 5, 5, 10, 10)"));

    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(resolved.getData(Relation.class).getProblems(), empty());
    Relation relation = resolved.getData(Relation.class).get();

    List<Tuple> collected = relation.stream().collect(Collectors.toList());
    assertThat(collected, contains(sourceRelation.getList().get(1)));
  }

  @Test
  public void aSyntacticallyInvalidFilterFailsToValidate() throws Exception {
    sourceRelation = new ListRelation(Struct.of("mystery", Types.TEXT));

    bookmark(ImmutableMap.of("filter", "bbox 1, 2, 3"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    assertThat(
      resolved.validate(),
      contains(
        equalIgnoringChildren(
          ParamProblems.get().bindingError(subject.getParameterSet().get("filter"))
        )
      )
    );
  }

  @Test
  public void canApplyAnAttributeFilterOnNonSpatialRelation() throws Exception {
    sourceType = Struct.of("word", Types.TEXT);
    sourceRelation = new ListRelation(Tuple.ofValues(sourceType, "hi"), Tuple.ofValues(sourceType, "mom"));

    bookmark(ImmutableMap.of("filter", "word = 'mom'"));
    List<Tuple> collected = subject.resolve(bookmark, executionContext).get().getData(Relation.class).get()
        .stream()
        .collect(Collectors.toList());

    assertEquals(1, collected.size());
    assertEquals("mom", collected.get(0).fetch("word"));
  }


  @Test
  public void typeProblemsWithFilterCausesFailureToGetData() throws Exception {
    sourceRelation = new ListRelation(Struct.of("foo", Types.GEOMETRY)).inferSpatialMetadata(project);

    bookmark(ImmutableMap.of("filter", "bbox(geometry, 1, 2, 3, 4)"));
    ResolvedBookmark resolved = subject.resolve(bookmark, executionContext).get();
    List<Problem> relationProblems = resolved.getData(Relation.class).getProblems();
    assertThat(relationProblems, contains(
        isProblem(Severity.ERROR, containsString("Could not apply bookmark supplied filter"))));

    assertThat(relationProblems.get(0).getChildren(), contains(
        ExpressionProblems.get().noSuchStructMember("geometry", Arrays.asList("foo"))
    ));
  }

  private void bookmark(Map<String, String> unparsed) {
    try {
      this.bookmark = new Bookmark("id", "desc", "bar", new URI("foo://bar/baz"),
          Maps.transformValues(unparsed, Collections::singletonList));
    } catch (URISyntaxException e) {
      fail(e.getMessage());
    }
  }

}
