/*
 * 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.defaults.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.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.geotools.geometry.jts.CompoundCurve;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;

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

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.StructMatchers;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.TupleMatchers;
import nz.org.riskscape.engine.TypeMatchers;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkProblems;
import nz.org.riskscape.engine.data.InputDataProblems;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.projection.LinearGeometryProjection;
import nz.org.riskscape.engine.relation.InvalidTupleException;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.types.MultiGeom;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.ResultOrProblems;


@RunWith(Parameterized.class)
public class GeoPackageRelationResolverTest extends BaseBookmarkResolverTest {

  @Parameters
  public static List<String> getItemTypes() {
    /*
     * We run these tests twice - once with no linearize-tolerance then with
     */
    return List.of("", "0.1");
  }

  private final String defaultLinearizeTolerance;

  public GeoPackageRelationResolverTest(String linearizeTolerance) {
    super("geopackage", engine -> new GeoPackageRelationResolver(engine));
    this.defaultLinearizeTolerance = linearizeTolerance;
  }

  @Test
  public void canReadPoints() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("points", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.POINT)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        allOf(
          TupleMatchers.tupleWithValue("name", is("point")),
          TupleMatchers.tupleWithValue("geom", GeometryMatchers.isInCrs(project.getSridSet(), SRIDSet.EPSG4326_LONLAT)),
          TupleMatchers.tupleWithValue("geom", GeometryMatchers.isGeometry(crs84Helper.fromWkt("POINT (10 10)")))
        )
    ));
    assertThat(relation.size().get(), is(1L));
  }

  @Test
  public void canReadPointInNZTM() throws Exception {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("nz-points", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    // Geopackages store geometries with an XY axis (same as shapefiles)
    GeometryHelper nztmXyHelper = GeometryHelper.nzTransverseMercatorXYWithOriginShift(project.getSridSet());

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(nztmXyHelper.getCrs(), Types.POINT)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        allOf(
          TupleMatchers.tupleWithValue("name", is("auckland")),
          TupleMatchers.tupleWithValue("geom", GeometryMatchers.isInCrs(project.getSridSet(), nztmXyHelper.getCrs())),
          TupleMatchers.tupleWithValue("geom",
              GeometryMatchers.isGeometry(nztmXyHelper.fromWkt("POINT (1690000 6015000)")))
        ),
        allOf(
          TupleMatchers.tupleWithValue("name", is("wellington")),
          TupleMatchers.tupleWithValue("geom", GeometryMatchers.isInCrs(project.getSridSet(), nztmXyHelper.getCrs())),
          TupleMatchers.tupleWithValue("geom",
              GeometryMatchers.isGeometry(nztmXyHelper.fromWkt("POINT (1684000 5460000)")))
        )
    ));
    assertThat(relation.size().get(), is(2L));
  }

  @Test
  public void canReadLines() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("lines", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line")
    ));
  }

  @Test
  public void canReadLinesWithZValues() {
    // test that RiskScape can read geometries with z/m values.
    // note we don't check that the z/m values themselves are read in correctly because riskscape doesn't
    // make use of them. Only the xy are validated.
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("lines_z", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geometry", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // JTS's Coordinate.java only compares xy values which lets this comparison work
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line_z")
    ));
  }

  @Test
  public void canRealLinesWithMValues() {
    // test that RiskScape can read geometries with z/m values.
    // note we don't check that the z/m values themselves are read in correctly because riskscape doesn't
    // make use of them. Only the xy are validated.
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("lines_m", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geometry", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // JTS's Coordinate.java only compares xy values which lets this comparison work
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line_m")
    ));
  }

  @Test
  public void canReadLinesWithZMValues() {
    // test that RiskScape can read geometries with z/m values.
    // note we don't check that the z/m values themselves are read in correctly because riskscape doesn't
    // make use of them. Only the xy are validated.
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("lines_zm", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geometry", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // JTS's Coordinate.java only compares xy values which lets this comparison work
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line_zm")
    ));
  }

  @Test
  public void canReadFromReadOnlyGeoPackage_GL1291() throws Exception {
    // riskscape#1291 was triggered by a geopackage with an incomplete transaction was run on a
    // readonly file system (an rclone mnt)
    // we can emulate this by making the file (and parent directory) readonly
    // Note: that removing the riskscape#1291 will make this test fail but the error will be slightly
    // different
    // Test:
    // Caused by: org.sqlite.SQLiteException: [SQLITE_READONLY_DIRECTORY] Process does not have permission
    // to create a journal file in the same
    // Platform mnt:
    // java.lang.RuntimeException: Unable to obtain connection: [SQLITE_CANTOPEN] Unable to open the
    // database file (unable to open database file)
    // not the same error, but have the same root cause - I think it's because the rclone mounted directory's
    // permissions are stuffed, so it couldn't even tell that it was read only.  Our fix was to apply not just
    // a read_only flag but also that `immutable=true` flag

    // use the incomplete transaction geopackage. this is a version of test.gpkg that is
    // - opened in qgis
    // - start editing a layer (enable edit on layer)
    // - kill qgis with xkill or similar
    Path gpkg = Path.of("build/resources/test/nz/org/riskscape/engine/defaults/data/readonly/"
        + "has-incomplete-transaction.gpkg");
    // we need to update the file permissions on the test file and it's parent directory.
    Set<PosixFilePermission> perms = new HashSet<>();
    perms.add(PosixFilePermission.OWNER_READ);
    perms.add(PosixFilePermission.OWNER_EXECUTE);
    perms.add(PosixFilePermission.GROUP_READ);
    perms.add(PosixFilePermission.OTHERS_READ);
    Files.setPosixFilePermissions(gpkg, perms);
    Files.setPosixFilePermissions(gpkg.getParent(), perms);

    // sanity check the geopackage and parent dir are readonly (this is important otherwise we aren't
    // really testing the fix)
    assertThat(Files.isWritable(gpkg), is(false));
    assertThat(Files.isWritable(gpkg.getParent()), is(false));

    try {
      Relation relation = from(bookmark(gpkg.toUri(), ImmutableMap.of("layer", "points"))).get();
      Struct relationType = relation.getProducedType();

      // no need to check the whole file, if the riskscape#1291 is not in play then the file could not be
      // read at all.
      assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.POINT)),
        StructMatchers.isStructMember("name", Types.TEXT)
      )));
    } finally {
      // we need to restore owner write or CI agents might not be able to unlink these files
      perms.add(PosixFilePermission.OWNER_WRITE);
      Files.setPosixFilePermissions(gpkg, perms);
      Files.setPosixFilePermissions(gpkg.getParent(), perms);
    }
  }

  @Ignore // without RsGpkgDataStoreFactory in play (which now requires linearize-tolerance to be set)
          // this test just fails because for this geopackage we can't get the crs if the curved geom
          // cannot be read. so for now just ignore the test. When the resolver is updated to use curved
          // support all the time we can restore the test.
  @Test
  public void curvedGeometriesAreInvalidByDefault() {
    Relation relation = from(
        "test.gpkg",
        // skip invalid so we get Invalid tuple exception rather than sunk problems
        ImmutableMap.of("layer", "compound-curve", "skip-invalid", "false")
    ).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    InvalidTupleException ex = assertThrows(InvalidTupleException.class,
        () -> relation.iterator().collect(Collectors.toList()));
    assertThat(ex.getProblem(), is(InputDataProblems.get().invalidTupleError().withChildren(
        LinearGeometryProjection.PROBLMES.unexpectedCurvedGeometry(
            CompoundCurve.class,
            LinearGeometryProjection.PROBLMES.enableLinearizeGeometryHint()
        )
    )));
  }

  @Test
  public void canReadCompoundCurveWithLinearize() {
    Relation relation = from(
        "test.gpkg",
        layerAndLinearizeTolerance("compound-curve", "0.0")
    ).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // curved geometries are linearized
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line")
    ));
  }

  @Test
  public void canReadPolygons() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("polygons", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.POLYGON)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.fromWkt("POLYGON ((10 10,10 20,20 20,20 10,10 10))"), "polygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("POLYGON ((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55))"),
            "keyed-polygon")
    ));
  }

  @Test
  public void canReadCurvedPolygonsWithLinearize() {
    Relation relation = from(
        "test.gpkg",
        layerAndLinearizeTolerance("curve-polygons", "0.0")
    ).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.POLYGON)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // curved geometries are linearized
        Tuple.ofValues(relationType, crs84Helper.fromWkt("POLYGON ((10 10,10 20,20 20,20 10,10 10))"), "polygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("POLYGON ((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55))"),
            "keyed-polygon")
    ));
  }

  @Test
  public void canReadMultiPoints() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("mpoints", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, MultiGeom.MULTI_POINT)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.fromWkt("MULTIPOINT ((10 10),(20 20))"), "mpoint")
    ));
  }

  @Test
  public void canReadMultiLines() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("mlines", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, MultiGeom.MULTI_LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.fromWkt("MULTILINESTRING ((10 10,20 10),(20 20,30 20))"), "mline")
    ));
  }

  @Test
  public void canReadMultiCurvesWithLinearize() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("multi-curve", "0.0")).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, MultiGeom.MULTI_LINE)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        // curved geometries are linearized
        Tuple.ofValues(relationType, crs84Helper.fromWkt("MULTILINESTRING ((10 10,20 10),(20 20,30 20))"), "mline")
    ));
  }

  @Test
  public void canReadMultiPolygons() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("mpolygons", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, MultiGeom.MULTI_POLYGON)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, containsInAnyOrder(
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),((50 50,50 60,60 60,60 50,50 50)))"),
            "mpolygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),"
                + "((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55)))"),
            "keyed-mpolygon")
    ));
  }

  @Test
  public void canReadMultiSurfacesWithLinearize() {
    Relation relation = from(
        "test.gpkg",
        layerAndLinearizeTolerance("multi-surface", "0.0")
    ).get();
    Struct relationType = relation.getProducedType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, MultiGeom.MULTI_POLYGON)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, containsInAnyOrder(
        Tuple.ofValues(relationType,
            // curved geometries are linearized
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),((50 50,50 60,60 60,60 50,50 50)))"),
            "mpolygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),"
                + "((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55)))"),
            "keyed-mpolygon")
    ));
  }

  @Test
  public void canReadMixedGeometryTypes() {
    Relation relation = from("test.gpkg", layerAndLinearizeTolerance("mixed", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertThat(relationType, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom",
            TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.GEOMETRY)),
        StructMatchers.isStructMember("name", Types.TEXT)
    )));

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, containsInAnyOrder(
        Tuple.ofValues(relationType, crs84Helper.fromWkt("POINT (10 10)"), "point"),
        Tuple.ofValues(relationType, crs84Helper.fromWkt("LINESTRING (10 10,20 10)"), "line"),
        Tuple.ofValues(relationType, crs84Helper.fromWkt("POLYGON ((10 10,10 20,20 20,20 10,10 10))"), "polygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("POLYGON ((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55))"),
            "keyed-polygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),((50 50,50 60,60 60,60 50,50 50)))"),
            "mpolygon"),
        Tuple.ofValues(relationType,
            crs84Helper.fromWkt("MULTIPOLYGON (((10 10,10 20,20 20,20 10,10 10)),"
                + "((50 50,50 60,60 60,60 50,50 50),(55 55,56 55,56 56,55 56,55 55)))"),
            "keyed-mpolygon")
    ));
  }

  @Test
  public void returnsFirstLayerIfNotSpecified() {
    ResultOrProblems<Relation> relationOr = from("cities.gpkg");
    assertThat(render(relationOr.getProblems()), containsString("no layer parameter given"));
    Relation relation = relationOr.getWithProblemsIgnored();
    Struct relationType = relation.getType();

    assertExpectedCitiesType(relationType);

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.point(151, -33), "Sydney", "Largest city in Australia", 5300000.0D),
        Tuple.ofValues(relationType, crs84Helper.point(174, -36), "Auckland", "Largest city in New Zealand", 1700000D),
        Tuple.ofValues(relationType, crs84Helper.point(174, -41), "Wellington", "New Zealand's capital city", 436000D)
    ));
  }

  @Test
  public void returnsRequestedLayer() {
    Relation relation = from("cities.gpkg", layerAndLinearizeTolerance("nz-cities", defaultLinearizeTolerance)).get();
    Struct relationType = relation.getType();

    assertExpectedCitiesType(relationType);

    List<Tuple> tuples = relation.iterator().collect(Collectors.toList());
    assertThat(tuples, contains(
        Tuple.ofValues(relationType, crs84Helper.point(174, -36), "Auckland", "Largest city in New Zealand", 1700000D),
        Tuple.ofValues(relationType, crs84Helper.point(174, -41), "Wellington", "New Zealand's capital city", 436000D)
    ));
  }

  @Test
  public void errorIfGeoPackageHasNoFeatureLayers() {
    // a geopackage can have feature layers and/or raster layers.
    assertThat(from("test-raster1_noOgrContents.gpkg", Collections.emptyMap()), failedResult(hasAncestorProblem(
        is(BookmarkProblems.get().noFeatureLayers())
    )));

    // the no feature layers problem takes priority over the unknown layer problem.
    assertThat(from("test-raster1_noOgrContents.gpkg", ImmutableMap.of("layer", "nz-cities")),
        failedResult(hasAncestorProblem(
            is(BookmarkProblems.get().noFeatureLayers())
        )));
  }

  @Test
  public void anErrorIsProducedIfLinearizeToleranceIsNegative() throws Exception {
    Bookmark bookmark = bookmark(testUri("cities.gpkg"), ImmutableMap.of("linearize-tolerance", "-1"));
    ResolvedBookmark resolved = resolver.resolve(bookmark, executionContext).get();

    assertThat(
      resolved.validate(),
      contains(
        allOf(
          equalIgnoringChildren(
              GeneralProblems.get().badValue(-1D, "linearize-tolerance", ">= 0")
          )
        )
      )
    );
  }

  private void assertExpectedCitiesType(Struct got) {
    assertThat(got, StructMatchers.isStruct(Lists.newArrayList(
        StructMatchers.isStructMember("geom", TypeMatchers.isReferencedType(SRIDSet.EPSG4326_LONLAT, Types.POINT)),
        StructMatchers.isStructMember("name", Types.TEXT),
        StructMatchers.isStructMember("desc", Types.TEXT),
        StructMatchers.isStructMember("population", Types.FLOATING)
    )));
  }

  private Map<String, String> layerAndLinearizeTolerance(String layer, String linearizeTolerance) {
    if ("".equals(linearizeTolerance)) {
      // don't set linearize tolerance at all if blank
      return Map.of("layer", layer);
    }
    return Map.of("layer", layer, "linearize-tolerance", linearizeTolerance);
  }

}
