/*
 * 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 org.junit.Assert.*;
import static org.mockito.Mockito.*;

import static nz.org.riskscape.engine.Matchers.*;

import static org.hamcrest.Matchers.*;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import org.geotools.referencing.CRS;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Point;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.referencing.crs.DefaultGeographicCRS;

import nz.org.riskscape.engine.GeometryMatchers;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.RelationMatchers;
import nz.org.riskscape.engine.RiskscapeIOException;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.TupleMatchers;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.defaults.resource.HttpResourceLoader;
import nz.org.riskscape.engine.function.StringFunctions;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.engine.relation.CsvRelation;
import nz.org.riskscape.engine.relation.InvalidTupleException;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.relation.SpatialMetadata;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.resource.ResourceFactory;
import nz.org.riskscape.engine.resource.ResourceProblems;
import nz.org.riskscape.engine.resource.StringResource;
import nz.org.riskscape.engine.rl.DefaultOperators;
import nz.org.riskscape.engine.rl.GeometryFunctions;
import nz.org.riskscape.engine.rl.LanguageFunctions;
import nz.org.riskscape.engine.rl.LogicFunctions;
import nz.org.riskscape.engine.rl.MathsFunctions;
import nz.org.riskscape.engine.types.Geom;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.engine.types.Referenced;
import nz.org.riskscape.engine.types.TypeProblems;
import nz.org.riskscape.problem.Problems;

public class CsvResolverTest extends ProjectTest {

  CsvResolver resolver;
  URI url;
  GeometryHelper latLongHeler = GeometryHelper.latLong(project.getSridSet());

  @Before
  public void setup() throws Exception {
    resolver = new CsvResolver(engine);
    url = testUri("test.csv");

    project.getFunctionSet().insertFirst(DefaultOperators.INSTANCE);
    project.getFunctionSet().addAll(LanguageFunctions.FUNCTIONS);
    project.getFunctionSet().addAll(LogicFunctions.LOGIC_FUNCTIONS);
    project.getFunctionSet().addAll(new GeometryFunctions(engine).getFunctions());
    project.getFunctionSet().addAll(MathsFunctions.FUNCTIONS);
    project.getFunctionSet().addAll(StringFunctions.FUNCTIONS);

    engine.getResourceFactory().add(new HttpResourceLoader(engine));
  }

  @Test
  public void returnsAResolvedRelationWithTheCSVData() throws Exception {

    Bookmark bookmark = bookmark("csv", url);
    assertTrue(resolver.resolve(bookmark, executionContext).isPresent());
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    Relation relation = resolvedBookmark.getData(Relation.class).get();

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

    List<Tuple> expected = Arrays.asList(
        Tuple.ofValues(relation.getType(), "1", "2", "3"),
        Tuple.ofValues(relation.getType(), "4", "5", "6"));

    assertEquals(expected, collected);
  }

  @Test
  public void onlySupportsCsv() throws URISyntaxException {
    url = new URI("file://tmp/test-data");
    assertFalse(resolver.resolve(bookmark("xls", url), executionContext).isPresent());
    assertFalse(resolver.resolve(bookmark("csvs", url), executionContext).isPresent());
    assertTrue(resolver.resolve(bookmark("csv", url), executionContext).isPresent());
  }

  @Test
  public void supportsNonFileUris() throws URISyntaxException {
    assertTrue(resolver.resolve(bookmark("csv", new URI("unknown://foo.server.com/my-data.csv")), executionContext)
        .isPresent());
  }

  @Test
  public void throwsARiskscapeIOExceptionIfTheUnderlyingInputStreamThrows() throws URISyntaxException {
    URI target = new URI("http://example.com/this-is-a-csv.csv");
    RiskscapeIOException boom = new RiskscapeIOException("Boom Boom", new IOException("Boom"));
    StringResource resource = new StringResource(target, "my string") {
      @Override
      public InputStream getContentStream() throws RiskscapeIOException {
        throw boom;
      }
    };
    ResourceFactory rf = mock(ResourceFactory.class);
    when(rf.load(target)).thenReturn(resource);
    engine.setResourceFactory(rf);
    Bookmark bookmark = bookmark("csv", target);
    Optional<ResolvedBookmark> resolved = resolver.resolve(bookmark, executionContext);

    assertThat(resolved.get().getData(Relation.class),
        nz.org.riskscape.engine.Matchers.failedResult(
            nz.org.riskscape.engine.Matchers.hasAncestorProblem(is(Problems.caught(boom)))
        )
    );
  }

  @Test
  public void throwsARiskscapeIOExceptionIfTheUnderlyingResourceCannotLocalize() throws URISyntaxException {
    URI target = new URI("http://example.com/this-is-a-csv.csv");
    RiskscapeIOException boom = new RiskscapeIOException("Boom Boom", new IOException("Boom"));
    StringResource resource = new StringResource(target, "my string") {
      @Override
      public ResultOrProblems<Path> ensureLocal(Resource.Options options) {
        return ResultOrProblems.failed(ResourceProblems.get().ensureLocalNotSupported(getLocation()));
      }
    };
    ResourceFactory rf = mock(ResourceFactory.class);
    when(rf.load(target)).thenReturn(resource);
    engine.setResourceFactory(rf);
    Bookmark bookmark = bookmark("csv", target);
    Optional<ResolvedBookmark> resolved = resolver.resolve(bookmark, executionContext);

    assertThat(resolved.get().getData(Relation.class),
        nz.org.riskscape.engine.Matchers.failedResult(
            nz.org.riskscape.engine.Matchers.hasAncestorProblem(
                is(ResourceProblems.get().ensureLocalNotSupported(target))
            )
        )
    );
  }

  @Test
  public void canNominateAGeometryAttribute() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_wkt.txt"), mapOf(
        "geometry-attribute", "wkt"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertFalse(resolvedBookmark.validate().isEmpty());
    assertThat(resolvedBookmark.validate().stream().map(prob -> render(prob)).collect(Collectors.toList()),
        Matchers.contains(Matchers.containsString("No 'crs-name' value was provided")));

    CsvRelation relation = (CsvRelation) resolvedBookmark.getData(Relation.class).get();
    Struct type = relation.getType();
    assertTrue(type.getEntry("wkt").getType().find(Geom.class).isPresent());

    // no CRS specified, then the default CRS is it
    Referenced withCRS = type.getEntry("wkt").getType().find(Referenced.class).orElse(null);
    assertNotNull(withCRS);
    assertEquals(project.getDefaultCrs(), withCRS.getCrs());

    assertTrue(relation.getSpatialMetadata().isPresent());
    SpatialMetadata sm = relation.getSpatialMetadata().get();
    assertEquals(project.getDefaultCrs(), sm.getCrs());
    assertSame(type.getEntry("wkt"), sm.getGeometryStructMember());

    List<Tuple> t = relation.stream().collect(Collectors.toList());
    Point point = t.get(0).fetch("wkt");
    assertEquals(point.getX(), 0D, 0.1);
    assertEquals(point.getY(), 0D, 0.1);
    assertEquals(project.getSridSet().get(point.getSRID()), project.getDefaultCrs());
  }

  @Test
  public void canNominateAGeometryAttributeAndSpecifyCRS() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_wkt.txt"), mapOf(
        "geometry-attribute", "wkt",
        "crs-name", "EPSG:2193"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertTrue(resolvedBookmark.validate().isEmpty());

    CsvRelation relation = (CsvRelation) resolvedBookmark.getData(Relation.class).get();
    Struct type = relation.getType();
    assertTrue(type.getEntry("wkt").getType().find(Geom.class).isPresent());

    CoordinateReferenceSystem expectedCRS = CRS.decode("EPSG:2193");
    Referenced withCRS = type.getEntry("wkt").getType().find(Referenced.class).orElse(null);
    assertNotNull(withCRS);
    assertEquals(expectedCRS, withCRS.getCrs());

    assertTrue(relation.getSpatialMetadata().isPresent());
    SpatialMetadata sm = relation.getSpatialMetadata().get();
    assertEquals(expectedCRS, sm.getCrs());
    assertSame(type.getEntry("wkt"), sm.getGeometryStructMember());

    List<Tuple> t = relation.stream().collect(Collectors.toList());
    Point point = t.get(0).fetch("wkt");
    assertEquals(point.getX(), 0D, 0.1);
    assertEquals(point.getY(), 0D, 0.1);
    assertEquals(project.getSridSet().get(point.getSRID()), expectedCRS);
  }

  @Test
  public void canAssignTheResultOfExpressionToADefaultGeometryAttribute() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "geometry-expression", "create_point(lat, long)",
        "crs-name", "EPSG:4326",
        "crs-longitude-first", "true"
        ));
    doGeomFromExpressionTest(bookmark, Types.POINT);
  }

  @Test
  public void canMapAttributeToSetGeometry() throws Exception {
    Type dataType = Struct.of("the_geom", Types.GEOMETRY, "name", Types.TEXT);
    project.getTypeSet().add("with-geometry", dataType);

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "type", "with-geometry",
        "map-attribute.the_geom", "create_point(lat, long)",
        "crs-name", "EPSG:4326",
        "crs-longitude-first", "true"
        ));
    doGeomFromExpressionTest(bookmark, Types.POINT);
  }

  @Test
  public void canMapAttributeToSetGeometryAsPoint() throws Exception {
    Type dataType = Struct.of("the_geom", Types.POINT, "name", Types.TEXT);
    project.getTypeSet().add("with-point-geometry", dataType);

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "type", "with-point-geometry",
        "map-attribute.the_geom", "create_point(lat, long)",
        "crs-name", "EPSG:4326",
        "crs-longitude-first", "true"
        ));
    doGeomFromExpressionTest(bookmark, Types.POINT);
  }

  @Test
  public void mappingToWrongGeomSubtypeWillEndInInvalidTupleExceptionWithSkipInvalidFalse() throws Exception {
    Type dataType = Struct.of("the_geom", Types.LINE, "name", Types.TEXT);
    project.getTypeSet().add("with-line-geometry", dataType);

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_wkt.txt"), mapOf(
        "type", "with-line-geometry",
        "skip-invalid", "false",
        "map-attribute.the_geom", "geom_from_wkt(wkt)",
        "crs-name", "EPSG:4326",
        "crs-longitude-first", "true"
        ));
    InvalidTupleException ex = assertThrows(InvalidTupleException.class,
        () -> doGeomFromExpressionTest(bookmark, Types.LINE));
  }

  private void doGeomFromExpressionTest(Bookmark bookmark, Type expectedGeometryType) throws Exception {
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertTrue(resolvedBookmark.validate().isEmpty());

    CsvRelation relation = (CsvRelation) resolvedBookmark.getData(Relation.class).get();
    Struct type = relation.getType();
    assertTrue(type.getEntry("the_geom").getType().find(Geom.class).isPresent());

    CoordinateReferenceSystem expectedCRS = CRS.decode("EPSG:4326", true);
    Referenced withCRS = type.getEntry("the_geom").getType().find(Referenced.class).orElse(null);
    assertNotNull(withCRS);
    assertEquals(expectedCRS, withCRS.getCrs());
    assertEquals(expectedGeometryType, withCRS.getUnderlyingType());

    assertTrue(relation.getSpatialMetadata().isPresent());
    SpatialMetadata sm = relation.getSpatialMetadata().get();
    assertEquals(expectedCRS, sm.getCrs());
    assertSame(type.getEntry("the_geom"), sm.getGeometryStructMember());

    List<Tuple> t = relation.stream().collect(Collectors.toList());
    Point point = t.get(1).fetch("the_geom");
    assertEquals(point.getX(), 2D, 0.1);
    assertEquals(point.getY(), 2D, 0.1);
    assertEquals(project.getSridSet().get(point.getSRID()), expectedCRS);
  }

  @Test
  public void cannotSpecifyCRSWithoutGeometry() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_wkt.txt"), mapOf(
        "crs-name", "EPSG:2193"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertTrue(resolvedBookmark.validate().isEmpty());

    Struct fileType = Struct.of("wkt", Types.TEXT, "name", Types.TEXT);
    ResultOrProblems<Relation> relation = resolvedBookmark.getData(Relation.class);
    assertThat(relation.getProblems(), contains(
        hasAncestorProblem(is(TypeProblems.get().structMustHaveMemberType(Types.GEOMETRY, fileType)))
    ));
  }

  @Test
  public void cannotSpecifyBothNewAndOldFormOfAlias() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "crs-name", "EPSG:4326",
        "crs-force-xy", "true",
        "crs-longitude-first", "false"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertThat(resolvedBookmark.validate(), contains(
        isProblem(Severity.ERROR, ParamProblems.class, "mutuallyExclusive")
        ));
  }

  @Test
  public void typeDetectionSelectsBestTypes() {
    customProtocolTestingResourceLoader.addContent(
        "mixed-types",
        """
        ints,nullable_ints,floats,nullable_floats,mixed
        1,1,2.5,2.5,3
        2,,2.8,,three
        3,3,2.8,2.8,
        """
    );
    Struct expectedType = Struct.of(
        "ints", Types.FLOATING,
        "nullable_ints", Nullable.FLOATING,
        "floats", Types.FLOATING,
        "nullable_floats", Nullable.FLOATING,
        "mixed", Types.TEXT);

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", URI.create("test:mixed-types"), mapOf(
        "type-detection", "true"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    Relation relation = resolvedBookmark.getData(Relation.class).get();

    assertThat(relation, RelationMatchers.relationWithType(expectedType));
    assertThat(relation, RelationMatchers.relationWithTuples(
        Tuple.ofValues(expectedType, 1D, 1D, 2.5D, 2.5D, "3"),
        Tuple.ofValues(expectedType, 2D, null, 2.8D, null, "three"),
        Tuple.ofValues(expectedType, 3D, 3D, 2.8D, 2.8D, "")
    ));
  }

  @Test
  public void typeDetectionDoesNotAddPointWhenGeometryAttributeIsIncluded() {
    // type detection will not add geometry if one has been created by some other means like geometry-attribute
    customProtocolTestingResourceLoader.addContent(
        "has-wkt",
        """
        ints,lat,long,wkt
        1,1,10,"POINT(10 10)"
        """
    );
    Struct expectedType = Struct.of(
        "ints", Types.FLOATING,
        "lat", Types.FLOATING,
        "long", Types.FLOATING,
        "wkt", Referenced.of(Types.GEOMETRY, DefaultGeographicCRS.WGS84)); // DefaultProject#getDefaultCrs

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", URI.create("test:has-wkt"), mapOf(
        "type-detection", "true",
        "geometry-attribute", "wkt"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    Relation relation = resolvedBookmark.getData(Relation.class).get();

    assertThat(relation, RelationMatchers.relationWithType(expectedType));
    assertThat(relation, RelationMatchers.relationWithTuples(
        Tuple.ofValues(expectedType, 1D, 1D, 10D, latLongHeler.point(10D, 10D))
    ));
  }

  @Test
  public void typeDetectionAddsPointFromLatLongWithVariousColumnNames() {
    doLatLongTest("lat", "long");
    doLatLongTest("lat", "lon");
    doLatLongTest("latitude", "longitude");
    // mixed case don't matter
    doLatLongTest("LatiTude", "LongiTude");
  }

  void doLatLongTest(String latAttr, String longAttr) {
    customProtocolTestingResourceLoader.addContent(
        "has-lat-lon",
        String .format("""
        ints,%s,%s
        1,1,10
        """, latAttr, longAttr)
    );
    Struct expectedType = Struct.of(
        "ints", Types.FLOATING,
        latAttr, Types.FLOATING,
        longAttr, Types.FLOATING,
        "geometry", Referenced.of(Types.POINT, SRIDSet.EPSG4326_LATLON));

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", URI.create("test:has-lat-lon"), mapOf(
        "type-detection", "true"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    Relation relation = resolvedBookmark.getData(Relation.class).get();

    assertThat(relation, RelationMatchers.relationWithType(expectedType));

    assertThat(relation, RelationMatchers.relationWithTuples(contains(
        TupleMatchers.tupleWithValue("geometry", allOf(
            GeometryMatchers.isGeometry(latLongHeler.point(1D, 10D)),
            // we need to ensure that the SRID is set correctly or reprojections just won't work
            GeometryMatchers.geometryWithSrid(project.getSridSet().get(SRIDSet.EPSG4326_LATLON)))
        )
    )));
  }


  @Test
  public void cannotSpecifyBothTypeAndTypeDetection() throws Exception {
    Type dataType = Struct.of("the_geom", Types.POINT, "name", Types.TEXT);
    project.getTypeSet().add("with-point-geometry", dataType);

    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "type", "with-point-geometry",
        "type-detection", "true"
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertThat(resolvedBookmark.validate(), contains(
        ParamProblems.get().mutuallyExclusive("type", "type-detection")
    ));
  }

  @Test
  public void canNominateAndAssignTheResultOfExpressionToAGeometryAttribute() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv data", "csv", testUri("with_lat_long.txt"), mapOf(
        "geometry-expression", "create_point(lat, long)",
        "geometry-attribute", "cool-point",
        "crs-name", "EPSG:4326",
        "crs-force-xy", "true" // aka crs-longitude-first
        ));
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    assertTrue(resolvedBookmark.validate().isEmpty());

    CsvRelation relation = (CsvRelation) resolvedBookmark.getData(Relation.class).get();
    Struct type = relation.getType();
    assertTrue(type.getEntry("cool-point").getType().find(Geom.class).isPresent());

    CoordinateReferenceSystem expectedCRS = CRS.decode("EPSG:4326", true);
    Referenced withCRS = type.getEntry("cool-point").getType().find(Referenced.class).orElse(null);
    assertNotNull(withCRS);
    assertEquals(expectedCRS, withCRS.getCrs());

    assertTrue(relation.getSpatialMetadata().isPresent());
    SpatialMetadata sm = relation.getSpatialMetadata().get();
    assertEquals(expectedCRS, sm.getCrs());
    assertSame(type.getEntry("cool-point"), sm.getGeometryStructMember());

    List<Tuple> t = relation.stream().collect(Collectors.toList());
    Point point = t.get(1).fetch("cool-point");
    assertEquals(point.getX(), 2D, 0.1);
    assertEquals(point.getY(), 2D, 0.1);
    assertEquals(project.getSridSet().get(point.getSRID()), expectedCRS);
  }

  @Test
  public void canAskForLineNumbersToBeIncluded() throws Exception {
    Bookmark bookmark = new Bookmark("csv", "csv", "csv", url, mapOf("add-line-numbers", "true"));
    assertTrue(resolver.resolve(bookmark, executionContext).isPresent());
    ResolvedBookmark resolvedBookmark = resolver.resolve(bookmark, executionContext).get();
    Relation relation = resolvedBookmark.getData(Relation.class).get();

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

    List<Tuple> expected = Arrays.asList(
        Tuple.ofValues(relation.getType(), "1", "2", "3", 2L),
        Tuple.ofValues(relation.getType(), "4", "5", "6", 3L));

    assertEquals(expected, collected);
  }

  @Test
  public void giveGoodErrorOnMissingRemoteFile() throws Exception {
    URI target = URI.create("https://" + HTTPS_TEST_URI + "/bogus/test.csv");
    Bookmark bookmark = new Bookmark("test", "my test bookmark", "csv",
        target, Map.of());

    assertThat(
        resolver.resolve(bookmark, executionContext).get().getData(Relation.class),
        nz.org.riskscape.engine.Matchers.failedResult(
            nz.org.riskscape.engine.Matchers.hasAncestorProblem(is(ResourceProblems.get().notFound(target)))
        )
    );
  }


  private Bookmark bookmark(String format, URI absoluteUrl) {
    return new Bookmark("csv", "csv data", format, absoluteUrl, Collections.emptyMap());
  }

  private URI testUri(String string) {
    try {
      return getClass().getResource("/nz/org/riskscape/engine/defaults/data/" + string).toURI();
    } catch (URISyntaxException e) {
      throw new RuntimeException(e);
    }
  }

  private Map<String, List<String>> mapOf(String... keyValues) {
    Map<String, List<String>> map = new HashMap<>(keyValues.length / 2);
    for (int i = 0; i < keyValues.length;) {
      map.put(keyValues[i++], Arrays.asList(keyValues[i++]));
    }
    return map;
  }
}
