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

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

import java.net.URI;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;

import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.referencing.factory.epsg.CartesianAuthorityFactory;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Test;

import com.google.common.collect.ImmutableMap;

import lombok.Getter;
import nz.org.riskscape.engine.ArgsProblems;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.Matchers;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.RelationMatchers;
import nz.org.riskscape.engine.RiskscapeException;
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.bind.ParameterField;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.BaseBookmarkResolver;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkParameters;
import nz.org.riskscape.engine.data.BookmarkProblems;
import nz.org.riskscape.engine.data.PickledDataBookmarkResolver;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.defaults.data.CsvResolver;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.relation.EmptyRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.rl.RealizableFunction;
import nz.org.riskscape.engine.rl.RealizedExpression;
import nz.org.riskscape.engine.types.CoverageType;
import nz.org.riskscape.engine.types.Referenced;
import nz.org.riskscape.engine.types.RelationType;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Text;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.TypeProblems;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.types.WithMetadata;
import nz.org.riskscape.engine.typexp.TypeBuildingException;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.rl.ast.FunctionCall;

@SuppressWarnings("unchecked")
public class LookupBookmarkTest extends ProjectTest {

  @Getter
  public static class TestParameters extends BookmarkParameters {

    @ParameterField
    Double width;

    @ParameterField
    long height = 100;

    @ParameterField
    List<String> foos;

    @Override
    public Class<?> getDataType() {
      return TestParameters.class;
    }
  }

  /**
   * TestResolver that returns the parameters when resolved.
   */
  class TestResolver extends BaseBookmarkResolver<TestParameters> {

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

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

    @Override
    protected void validateParameters(TestParameters testParams, BindingContext bindingContext) {}

    @Override
    protected ResultOrProblems<Object> build(TestParameters testParams) {
      return ResultOrProblems.of(testParams);
    }

  }

  LookupBookmark lookupBookmark = new LookupBookmark();
  URI csvColumnFoo = getPackagePath().resolve("column-foo.csv").toUri();
  URI csvColumnbar = getPackagePath().resolve("column-bar.csv").toUri();

  // works fine for constant/static bookmark evaluation cases
  Tuple inputTuple = tuple("{}");
  ResultOrProblems<RealizedExpression> realizedOr;
  RealizedExpression realized;
  Type returnType;
  List<Problem> problems = List.of();

  EmptyRelation emptyRelation = new EmptyRelation(Types.TEXT.asStruct());
  TypedCoverage emptyCoverage = TypedCoverage.empty(Types.INTEGER);

  Bookmark spiedBookmark;

  // prepare a spy for use with tests that want to assert what parameters were given
  PickledDataBookmarkResolver.Spy paramsSpy = (bookmark, resolved, context) -> {
    TestResolver testResolver = new TestResolver(engine);
    spiedBookmark = bookmark;
    return testResolver.resolve(bookmark, executionContext).get();
  };

  // this spy returns the data, but captures the bookmark
  PickledDataBookmarkResolver.Spy bookmarkSpy = (bookmark, resolved, context) -> {
    spiedBookmark = bookmark;
    return resolved;
  };
  private FunctionCall parsedFunctionCall;

  @Before
  public void addCsvResolver() {
    engine.getBookmarkResolvers().add(new CsvResolver(engine));
    project.getFunctionSet().add(RealizableFunction.asFunction(lookupBookmark, lookupBookmark.getArguments(),
        Types.ANYTHING).identified("bookmark"));

    addPickledData("relation", emptyRelation);
    addPickledData("coverage", emptyCoverage);
  }

  @Test
  public void willGiveAFunctionThatYieldsTheCoverageResolvedBookmark() {
    realize("bookmark('coverage')");
    assertThat(problems, empty());

    assertEqualIncludingMetadata(returnType, emptyCoverage.getScalarDataType(), "coverage");
    assertSame(emptyCoverage, call());
  }

  @Test
  public void willGiveAFunctionThatYieldsTheRelationResolvedBookmark() {
    realize("bookmark('relation')");
    assertThat(problems, empty());

    assertEqualIncludingMetadata(returnType, emptyRelation.getScalarDataType(), "relation");
    assertSame(emptyRelation, call());
  }

  @Test
  public void canPassOptionsAndHaveThemBound() {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test', width: 10.5, height: 12})");

    TestParameters params =  call();
    assertThat(params.width, is(10.5D));
    assertThat(params.height, is(12L));
  }

  @Test
  public void canPassOptionsAndHaveThemBoundFromNestedBookmark() {
    bookmarkResolver.spy = paramsSpy;

    realize(String.format(
        """
        bookmark(
          bookmark('relation', {format: 'test', width: 10.5, height: 12}),
          {location: '%s', width: 9.0}
        )
        """,
        csvColumnFoo
    ));

    TestParameters params =  call();
    assertThat(params.width, is(9D));   // from outer bookmark
    assertThat(params.height, is(12L)); // from inner bookmark

    bookmarkResolver.spy = bookmarkSpy;
    call();
    assertThat(spiedBookmark.getLocation(), is(csvColumnFoo));  // from outer bookmark
  }

  @Test
  public void canPassOptionsAndHaveThemBoundWithCoercion() {
    bookmarkResolver.spy = paramsSpy;

    // width will do int -> double conversion, height will do string
    realize("bookmark('relation', {format: 'test', width: 10, height: '12'})");

    TestParameters params = call();
    assertThat(params.width, is(10.0D));
    assertThat(params.height, is(12L));
  }

  @Test
  public void optionsBindingWarningsAreHandled() {
    bookmarkResolver.spy = paramsSpy;

    // what happens when binding won't work, lets try a float -> int on height
    realize("bookmark('relation', {format: 'test', width: 10.2, height: 12.7})");

    TestParameters params = call();
    assertThat(params.width, is(10.2D));
    assertThat(params.height, is(12L));

    // check for precision loss warning - not sure why, but the expression realizer sends this to the problem sink,
    // rather than returning it with the expression result.
    assertThat(sunkProblems, contains(
        Matchers.hasAncestorProblem(is(GeneralProblems.get().precisionLoss(12.7D, 12L)))
    ));
  }

  @Test
  public void optionsBindingSupportsListOfStrings() {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test', width: 0, foos: ['foo1', 'foo2']})");
    TestParameters params = call();
    assertThat(params.foos, contains("foo1", "foo2"));
  }

  @Test
  public void optionsBindingSupportsListOfStringsGivenSingularly() {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test', width: 0, foos: 'foo'})");

    assertThat(call(), hasProperty("foos", contains("foo")));
  }

  @Test
  public void optionsBindingSupportsNonListParamsSuppliedViaList() {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test', width: ['10']})");
    assertThat(call(), hasProperty("width", is(10D)));
  }

  @Test
  public void optionsBindingGivesErrorIfNonListParamGivenTooManyValues() {
    bookmarkResolver.spy = paramsSpy;
    realize("bookmark('relation', {format: 'test', height: [1, 2, 3]})");
    assertAncestorProblem(
      ParamProblems.get().wrongNumberGiven("height", "1", 3)
    );
  }

  @Test
  public void canPassUriLocationContainingSpaces() {
    String location = getPackagePath().toUri().toString() + "/filename with spaces.csv";
    // sanity check it's a file URI (of course it is)
    assertThat(location, startsWith("file:/"));
    realize("bookmark('" + location + "')");

    // check that the returned relation is the expected type, that's enough to show that the location was read
    assertThat(call(), RelationMatchers.relationWithType(Struct.of("foo", Types.TEXT)));
  }

  @Test
  public void optionsBindingAllowsOriginalBookmarkLocationToBeReplaced() throws Exception {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {location: 'https://riskscape.nz/different.test'})");
    assertThat(spiedBookmark.getLocation(), equalTo(URI.create("https://riskscape.nz/different.test")));
  }

  @Test
  public void optionsBindingAllowsOriginalBookmarkLocationToBeReplacedWithRelativePath() throws Exception {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {location: 'different.test'})");
    // relative to pwd
    assertThat(spiedBookmark.getLocation(), equalTo(Paths.get(".").resolve("different.test").normalize().toUri()));
  }

  @Test
  public void optionsBindingAllowsFormatToBeReplaced() throws Exception {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test'})");
    assertThat(spiedBookmark.getFormat(), equalTo("test"));
  }

  @Test
  public void optionsBindingAllowsOptionsToBeReplaced() throws Exception {
    bookmarkResolver.spy = paramsSpy;

    realize("bookmark('relation', {format: 'test'})");
    assertThat(spiedBookmark.getFormat(), equalTo("test"));
  }

  @Test
  public void willPreserveReferencedGeometryOnARelation() {
    // not sure why this is a special case, but someone added this test and thought it was important at the time?
    CoordinateReferenceSystem crs = CartesianAuthorityFactory.GENERIC_2D;
    ReferencedEnvelope bounds = new ReferencedEnvelope(crs);
    bounds.expandToInclude(1, 1);

    Struct relationType = Struct.of("geom", Referenced.of(Types.GEOMETRY, crs, bounds));
    Relation relation = new EmptyRelation(relationType);
    addPickledData("referenced", relation);
    realize("bookmark('referenced')");
    assertEqualIncludingMetadata(returnType, relation.getScalarDataType(), "referenced");
    assertSame(relation, call());
  }

  @Test
  public void simpleDataCanAlsoBeReturnedThanksToResolvedBookmarksScalarType() throws Exception {
    String data = "not a coverage or a relation";
    addPickledData("opaque-data", data);

    realize("bookmark('opaque-data')");
    assertThat(problems, empty());

    assertEqualIncludingMetadata(returnType, Types.TEXT, "opaque-data");
  }

  @Test
  public void failsIfExpressionIsNotAConstantAndNoType() throws Exception {
    inputTuple = tuple("{id: 'relation'}");
    realize("bookmark(id)");

    assertAncestorProblem(ArgsProblems.get().required("type").withChildren(LookupBookmark.PROBLEMS.typeRequiredHint()));
  }

  @Test
  public void failsIfOptionsNotStruct()  throws Exception {
    realize("bookmark('relation', options: 'bar')");

    assertThat(
        problems,
        contains(
            TypeProblems.get().mismatch(parsedFunctionCall.getArguments().get(1), Struct.class, Text.class)
        )
    );
  }

  @Test
  public void failsIfOptionsStructHasBadMember()  throws Exception {
    realize("bookmark('relation', options: {foo: {}})");

    assertThat(
        problems,
        contains(LookupBookmark.PROBLEMS.optionValueMustBeTextNumericOrBoolean("foo", Struct.EMPTY_STRUCT))
    );
  }

  @Test
  public void failsIfBookmarkTypeDoesNotMatchGiven() {
    realize("bookmark('coverage', options: {}, type: 'coverage(text)')");

    assertAncestorProblem(TypeProblems.get().mismatch(
        project.getBookmarks().get("coverage", ProblemSink.DEVNULL).toString(),
        new CoverageType(Types.TEXT),
        emptyCoverage.getScalarDataType()
    ));
  }

  @Test
  public void failsIfTypeDoesNotParse() {
    realize("bookmark('coverage', options: {}, type: 'coverage')");

    assertAncestorProblem(Matchers.isProblem(TypeBuildingException.class));
  }

  @Test
  public void idCanBeDynamicCoverage() {
    inputTuple = tuple("{id: 'coverage'}");
    realize("bookmark(id, options: {}, type: 'coverage(integer)')");

    assertThat(returnType, is(new CoverageType(Types.INTEGER)));
    assertThat(call(), sameInstance(emptyCoverage));

    // add another and do the call again
    TypedCoverage alternate = TypedCoverage.empty(Types.INTEGER);
    addPickledData("alternate", alternate);
    inputTuple = tuple("{id: 'alternate'}");

    // sanity check in case we ever memoize these empty instances
    assertThat(alternate, not(emptyCoverage));
    assertThat(call(), sameInstance(alternate));
  }

  @Test
  public void idCanBeDynamicRelation() {
    inputTuple = tuple("{id: 'relation'}");
    realize("bookmark(id, options: {}, type: 'relation(struct(value: text))')");
    assertThat(returnType, is(new RelationType(Types.TEXT.asStruct())));

    assertThat(call(), sameInstance(emptyRelation));

    // add in a second bookmark and test it is dynamic
    inputTuple = tuple("{id: 'alternate'}");
    Relation alternate = new EmptyRelation(emptyRelation.getProducedType());
    addPickledData("alternate", alternate);

    // sanity check in case we ever memoize these empty instances
    assertThat(alternate, not(emptyRelation));
    assertThat(call(), sameInstance(alternate));
  }

  @Test
  public void wrongDynamicRelationTypeGivesExecutionError() {
    inputTuple = tuple("{id: 'relation'}");
    // set expected type to be int, not text, like the test's default case
    realize("bookmark(id, options: {}, type: 'relation(struct(value: integer))')");
    // realizes just fine
    assertThat(problems, empty());
    // confirm that the declared type is what is returned
    assertThat(returnType, is(new RelationType(Types.INTEGER.asStruct())));

    Problem expectedProblem = TypeProblems.get().mismatch(
        project.getBookmarks().get("relation", ProblemSink.DEVNULL).toString(),
        returnType,
        emptyRelation.getScalarDataType()
    );

    RiskscapeException ex = assertThrows(RiskscapeException.class, () -> call());
    assertThat(
        ex.getProblem(),
        Matchers.hasAncestorProblem(is(expectedProblem))
    );
  }

  @Test
  public void optionsCanBeDynamic() {
    inputTuple = tuple("{uri: '%s'}".formatted(csvColumnFoo));

    realize("bookmark('relation', options: {location: uri})");

    // we use the spy to check that the bookmark location is updated as expected
    bookmarkResolver.spy = bookmarkSpy;
    call();

    // assert the location got updated - note the actual data is still the pickled value because of how the pickled
    // resolver works - the explicit type test below asserts that the swap is actually happening
    assertThat(spiedBookmark.getLocation(), is(csvColumnFoo));
  }

  @Test
  public void optionsCanBeDynamicWithExplicitType() {
    // uri is csv with a bar column
    inputTuple = tuple("{uri: '%s'}".formatted(csvColumnbar));

    // the id is a uri for a csv with foo column, but the type should match bar - this shows that the 'type' arg wins
    realize("bookmark('%s', options: {location: uri}, type: 'relation(struct(bar: text))')".formatted(csvColumnFoo));

    // check we got the bar data, not the foo data
    assertThat(
        call(),
        RelationMatchers.relationWithTuples(tuple("{bar: 'bar value'}"))
    );
  }

  @Test
  public void failsIfDynamicOptionsBadPlaceholderAndNoType() throws Exception {
    inputTuple = tuple("{uri: '%s'}".formatted(csvColumnFoo));

    // constant bookmark ID given but it's a bad placeholder bookmark
    realize("bookmark('does-not-exist', {location: uri})");

    assertThat(
        problems,
        contains(
          Matchers.hasAncestorProblem(is(BookmarkProblems.get().notBookmarkOrFile("does-not-exist"))),
          Matchers.hasAncestorProblem(is(LookupBookmark.PROBLEMS.badPlaceholderHint()))
       ));
  }

  @Test
  public void failsIfDynamicOptionsButBookmarkFailsToRealizeWithNoType() throws Exception {
    inputTuple = tuple("{uri: '%s', id: 'foo'}".formatted(csvColumnFoo));

    // dynamic bookmark ID given, so it definitely requires a type args
    realize("bookmark(id, {location: uri})");

    assertAncestorProblem(ArgsProblems.get().required("type")
      .withChildren(LookupBookmark.PROBLEMS.typeRequiredHint()));
  }

  @Test
  public void optionsCanBeDynamicWithBadBookmarkAsLongAsExplicitTypeGiven() {
    // uri is csv with a bar column
    inputTuple = tuple("{uri: '%s'}".formatted(csvColumnbar));

    // bogus does not exist, but it does not matter as type is given
    realize("bookmark('bogus', options: {location: uri}, type: 'relation(struct(bar: text))')");

    // check we got the bar data, not the foo data
    assertThat(
        call(),
        RelationMatchers.relationWithTuples(tuple("{bar: 'bar value'}"))
    );
  }

  @Test
  public void exceptionWhenBookmarkReturnsCoverageWithUnexpectedType() {
    TypedCoverage alternate = TypedCoverage.empty(Types.FLOATING);
    URI alternateUri = URI.create("test:alternate");
    bookmarkResolver.spy = (bm, found, context) -> {
      if (alternateUri.equals(bm.getLocation())) {
        return ResolvedBookmark.stub(bm, alternate);
      } else {
        return found;
      }
    };

    inputTuple = tuple("{location: 'test:alternate'}");
    // coverage is of type integer, alternate is floating
    realize("bookmark('coverage', options: {location: location})");

    Problem expectedProblem = TypeProblems.get().mismatch(
        "Bookmark[id: coverage, location: test:alternate]",
        returnType,
        alternate.getScalarDataType()
    );

    RiskscapeException ex = assertThrows(RiskscapeException.class, () -> call());
    assertThat(
        ex.getProblem(),
        Matchers.hasAncestorProblem(is(expectedProblem))
    );
  }

  @Test
  public void exceptionWhenBookmarkReturnsCoverageWithUnexpectedType_ExplicitTypeArg() {
    inputTuple = tuple("{id: 'coverage'}");
    realize("bookmark(id, options: {}, type: 'coverage(floating)')");

    Problem expectedProblem = TypeProblems.get().mismatch(
        project.getBookmarks().get("coverage", ProblemSink.DEVNULL).toString(),
        returnType,
        emptyCoverage.getScalarDataType()
    );

    RiskscapeException ex = assertThrows(RiskscapeException.class, () -> call());
    assertThat(
        ex.getProblem(),
        Matchers.hasAncestorProblem(is(expectedProblem))
    );
  }

  @Test
  public void exceptionWhenBookmarkReturnsTotallyDifferentType() {
    // we lookup a relation, but expect a coverage
    inputTuple = tuple("{id: 'relation'}");
    realize("bookmark(id, options: {}, type: 'coverage(integer)')");

    Problem expectedProblem = TypeProblems.get().mismatch(
        project.getBookmarks().get("relation", ProblemSink.DEVNULL).toString(),
        returnType,
        emptyRelation.getScalarDataType()
    );

    RiskscapeException ex = assertThrows(RiskscapeException.class, () -> call());
    assertThat(
        ex.getProblem(),
        Matchers.hasAncestorProblem(is(expectedProblem))
    );
  }

  @Test
  public void failsToRealizeIfThereIsAConstantProblemWithBookmarkResolving() throws Exception {
    // pop a broken bookmark in to the project
    Problem problem = Problems.foundWith("aargh");
    addPickledBookmark(ResolvedBookmark.withId("failed", "ignored", List.of(problem), List.of()));

    realize("bookmark('failed')");
    assertAncestorProblem(problem);
  }

  @Test
  public void failsToRealizeWithExtraArgs() {
    realize("bookmark('relation', {}, 'coverage(integer)', 'bogus-argument')");
    assertAncestorProblem(is(ArgsProblems.get().wrongNumberRange(1, 3, 4)));
  }

  @Test
  public void lookupFallsBackToRelativeFileIfNotBookmark() throws Exception {
    realize("bookmark('might-be-a-file.txt')");

    assertAncestorProblem(
      BookmarkProblems.get().notBookmarkOrFile("might-be-a-file.txt")
    );
  }

  @Test
  public void pipelineExpressionCanStillExtendBookmarkParameter() throws Exception {
    bookmarkResolver.spy = this.paramsSpy;
    // wrap up the inner bookmark and change the height
    // This is a simplistic example, but a more real world example would be:
    //   bookmark($hdf5_file, { dataset: 'siteids' })
    //   bookmark($hdf5_file, { dataset: 'events' })
    // where $hdf5_file could be coming in from the UI as a `bookmark('some-file.hdf5')` expression
    realize("bookmark(bookmark('relation', {format: 'test', width: 10.5}), {height: 12})");
    TestParameters params = call();

    // bookmark should use outer height, but inner width
    assertThat(params.height, is(12L));
    assertThat(params.width, is(10.5));
  }

  /**
   * Realize the given functional call expression against the inputTuple's type
   */
  private void realize(String expression) {
    parsedFunctionCall = ExpressionParser.parseString(expression).isA(FunctionCall.class).get();

    realizedOr = realizationContext.getExpressionRealizer()
        .realize(inputTuple.getStruct(), parsedFunctionCall);

    if (realizedOr.isPresent()) {
      realized = realizedOr.orElse(null);
      returnType = realized.getResultType();
    }

    problems = realizedOr.getProblems();

    if (problems.size() == 1) {
      // remove the wrapping problem
      problems = problems.get(0).getChildren();
      assertThat(problems, not(empty()));
    }
  }

  /**
   * Call the realized function expression against the inputTuple
   */
  private <T> T call() {
    if (realized == null) {
      fail("Realization failed: " + problems);
    }
    return (T) realized.evaluate(inputTuple);
  }

  private void assertEqualIncludingMetadata(Type actual, Type expected, String bookmarkId) {
    // check underlying type is equal - this ignores Referenced and WithMetadata
    assertThat(actual.getUnwrappedType(), equalTo(expected.getUnwrappedType()));
    assertTrue(returnType instanceof WithMetadata);
    // ignore/strip off the metadata and check equal - this includes Referenced
    WithMetadata metadataType = (WithMetadata) actual;
    assertThat(metadataType.getUnderlyingType(), equalTo(expected));

    // check we can access the bookmark used to build the result
    assertThat(
        metadataType.getMetadata(Bookmark.class).get(),
        is(project.getBookmarks().get(bookmarkId, ProblemSink.DEVNULL))
    );
  }

  private void assertAncestorProblem(Matcher<Problem> expected) {
    assertThat(
        problems,
        contains(Matchers.hasAncestorProblem(expected))
    );
  }

  private void assertAncestorProblem(Problem expected) {
    assertAncestorProblem(is(expected));
  }
}
