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

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

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import org.geotools.geometry.jts.ReferencedEnvelope;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;

import nz.org.riskscape.engine.Matchers;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.defaults.function.LookupBookmark;
import nz.org.riskscape.engine.function.IdentifiedFunction;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.engine.relation.ListRelation;
import nz.org.riskscape.engine.rl.BaseExpressionRealizerTest;
import nz.org.riskscape.engine.rl.GeometryFunctions;
import nz.org.riskscape.engine.rl.RealizableFunction;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Referenced;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.TypeProblems;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.rl.ast.Constant;
import nz.org.riskscape.rl.ast.ListDeclaration;
import nz.org.riskscape.rl.ast.PropertyAccess;
import nz.org.riskscape.rl.ast.StructDeclaration;

@SuppressWarnings("unchecked")
public class LayerIntersectionsTest extends BaseExpressionRealizerTest {

  GeometryHelper nzGeometryHelper = GeometryHelper.nzMapGridWithOriginShift(project.getSridSet());
  GeometryHelper latLongGeometryHelper = GeometryHelper.latLong(project.getSridSet());

  Struct featureType;
  Struct scopeType;
  List<Tuple> rhsTuples = new ArrayList<>();
  private Object feature;
  private List<Tuple> results;


  @Before
  public void setup() {
    // featureType is rebuilt before each test, just in case a test changes it.
    featureType = Struct.of("name", Types.TEXT, "geom", Types.GEOMETRY);

    project.getFunctionSet().add(new LayerIntersections().identified("layer_intersections"));
    project.getFunctionSet().addAll(new GeometryFunctions(engine).getFunctions());

    LookupBookmark lookupBookmark = new LookupBookmark();
    project.getFunctionSet().add(
        RealizableFunction.asFunction(lookupBookmark, lookupBookmark.getArguments(), Types.ANYTHING)
            .builtin("bookmark", IdentifiedFunction.Category.MISC));
  }

  Polygon box(double x1, double y1, double x2, double y2) {
    return nzGeometryHelper.box(x1, y1, x2, y2);
  }

  LineString line(double x1, double y1, double x2, double y2) {
    return nzGeometryHelper.line(x1, y1, x2, y2);
  }

  @Test
  public void intersectingFeatureIsReturnedWithNewGeometry() {

    rhsTuples.add(Tuple.ofValues(featureType, "box", box(-1, -1, 1, 1)));
    feature = Tuple.ofValues(featureType, "foo", box(0, 0, 2, 2));

    evaluate();

    // should be the lhs feature, but with smaller geometry
    assertThat(
      results,
      contains(
        allOf(
          tupleWithValue("name", equalTo("foo")),
          tupleWithValue("geom", isGeometry(box(0, 0, 1, 1)))
        )
      )
    );
  }


  @Test
  public void anyIntersectionsThatProduceLesserGeometryTypesAreThrownAway() throws Exception {

    // These two should produce a point when intersecting the feature - it should not be considered an intersection
    // for the purposes of this method - mixing up geometry types causes trouble and is probably not what anyone wants
    // when creating new features
    Polygon fooPolygon = box(0, 0, 2, 2);
    Polygon glancingPolygon = box(2, 2, 3, 3);
    Geometry intersection = fooPolygon.intersection(glancingPolygon);
    // sanity check that they intersect, but not in a way that's useful to this function
    assertTrue(intersection instanceof Point);

    rhsTuples.add(Tuple.ofValues(featureType, "box", box(-1, -1, 1, 1)));
    rhsTuples.add(Tuple.ofValues(featureType, "glancing", glancingPolygon));

    feature = Tuple.ofValues(featureType, "foo", fooPolygon);

    evaluate();

    // 'glancing' is not present, only 'foo' is
    assertThat(
      results,
      contains(
        tupleWithValue("name", equalTo("foo"))
      )
    );
  }

  @Test
  public void canOptionallyMergeInAttributesFromTheRhs() throws Exception {

    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(-1, -1, 1, 1)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(0, 0, 2, 2));

    // assigns name to merged_name
    evaluate("{merged_name: 'name'}");

    assertThat(
      results,
      contains(
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithMemberType("name", equalTo(Types.TEXT)),
          tupleWithValue("merged_name", equalTo("rhs-box")),
          tupleWithMemberType("merged_name", equalTo(Types.TEXT))
        )
      )
    );
  }

  @Test
  public void canOptionallyReplaceAttributesFromTheRhs() throws Exception {
    Struct differentFeatureType = featureType.replace("name", Types.INTEGER);
    rhsTuples.add(Tuple.ofValues(differentFeatureType, 1L, box(-1, -1, 1, 1)));

    feature = Tuple.ofValues(featureType, "lhs-box", box(0, 0, 2, 2));

    // replaces the name with rhs's name
    evaluate("{name: 'name'}");

    assertThat(
      results,
      contains(
        allOf(
          // lhs value is replaced
          tupleWithValue("name", equalTo(1L)),
          // has changed to integer
          tupleWithMemberType("name", equalTo(Types.INTEGER))
        )
      )
    );
  }

  @Test
  public void returnsAnEmptyListIfNoIntersections() throws Exception {
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(-1, -1, 0, 0)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(1, 1, 2, 2));

    evaluate();

    assertThat(
      results,
      empty()
    );
  }

  @Test
  public void returnsTheOriginalFeatureIfNoIntersectionsAndIncludeDifference() throws Exception {
    // no intersection at all, return original feature
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(-1, -1, 0, 0)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(1, 1, 2, 2));

    evaluate("{}", "true");

    assertThat(
      results,
      contains(equalTo(feature))
    );
  }

  @Test
  public void returnsPartOfTheOriginalFeatureIfSomeIntersectionsAndIncludeDifference() throws Exception {
    // splits the feature in to two
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(1, 0, 2, 1)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(0, 0, 2, 1));

    evaluate("{}", "true");

    assertThat(
      results,
      containsInAnyOrder(
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(box(1, 0, 2, 1)))
        ),
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(box(0, 0, 1, 1)))
        )
      )
    );
  }

  @Test
  public void returnsDisconnectedPartsOfTheOriginalFeatureIndividuallyIfSomeIntersectionsAndIncludeDifference()
      throws Exception {
    // this is the case where the intersection splits the feature in to three, with one part an intersection and the
    // other two not
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(1, 0, 2, 1)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(0, 0, 3, 1));

    evaluate("{}", "true");


    assertThat(
      results,
      containsInAnyOrder(
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(box(1, 0, 2, 1)))
        ),
        // two parts of the difference
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(nzGeometryHelper.box(0, 0, 1, 1)))
        ),
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(nzGeometryHelper.box(2, 0, 3, 1)))
        )
      )
    );
  }

  @Test
  public void returnsDisconnectedPartsAsMultGeomIfLhsIsMulti()
      throws Exception {
    // this is the case where the intersection splits the feature in to three, with one part an intersection and the
    // other two not
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", nzGeometryHelper.box(1, 0, 2, 1)));
    feature = Tuple.ofValues(featureType, "lhs-box", nzGeometryHelper.multiBox(
        nzGeometryHelper.box(0, 0, 3, 1),
        nzGeometryHelper.box(10, 0, 13, 1)
    ));

    evaluate("{}", "true");


    assertThat(
      results,
      containsInAnyOrder(
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(box(1, 0, 2, 1)))
        ),
        // two parts of the difference combined into one multi
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("geom", isGeometry(nzGeometryHelper.multiBox(
              nzGeometryHelper.box(0, 0, 1, 1),
              nzGeometryHelper.box(2, 0, 3, 1),
              nzGeometryHelper.box(10, 0, 13, 1)
          )))
        )
      )
    );
  }

  @Test
  public void mergedInAttributesAreNullableWhenIncludingDifference() throws Exception {
    rhsTuples.add(Tuple.ofValues(featureType, "rhs-box", box(1, 0, 2, 1)));
    feature = Tuple.ofValues(featureType, "lhs-box", box(0, 0, 2, 1));

    evaluate("{other_name: 'name'}", "true");

    assertThat(
      results,
      containsInAnyOrder(
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithMemberType("name", equalTo(Types.TEXT)),
          tupleWithValue("other_name", equalTo("rhs-box")),
          tupleWithMemberType("other_name", equalTo(Nullable.TEXT)),
          tupleWithValue("geom", isGeometry(box(1, 0, 2, 1)))
        ),
        // this bit didn't intersect, so it doesn't copy any attributes across
        allOf(
          tupleWithValue("name", equalTo("lhs-box")),
          tupleWithValue("other_name", nullValue()),
          tupleWithValue("geom", isGeometry(box(0, 0, 1, 1)))
        )
      )
    );
  }

  @Test
  public void returnsEmptyListIfLhsGeomIsNull() throws Exception {
    featureType = Struct.of("geom", Nullable.GEOMETRY, "name", Types.TEXT);
    feature = Tuple.ofValues(featureType, null, "hi");

    evaluate();

    assertThat(results, empty());
  }


  @Test
  public void returnsOriginalFeatureIfReturnDifference() throws Exception {
    featureType = Struct.of("geom", Nullable.GEOMETRY, "name", Types.TEXT);
    feature = Tuple.ofValues(featureType, null, "hi");

    evaluate("{}", "true");

    assertThat(results, contains(equalTo(feature)));
  }

  @Test
  public void returnsOriginalFeatureIfReturnDifferenceWithNewAttributes() throws Exception {
    featureType = Struct.of("geom", Nullable.GEOMETRY, "name", Types.TEXT);
    feature = Tuple.ofValues(featureType, null, "hi");

    evaluate("{foo: 'name'}", "true");

    assertThat(
      results,
      contains(
        allOf(
          tupleWithValue("foo", nullValue()),
          tupleWithValue("name", equalTo("hi")),
          tupleWithValue("geom", nullValue())
        )
      )
    );
  }

  @Test
  public void returnsOriginalFeatureIfReturnDifferenceWithReplacedAttributes() throws Exception {
    featureType = Struct.of("geom", Nullable.GEOMETRY, "name", Types.TEXT);
    feature = Tuple.ofValues(featureType, null, "hi");

    evaluate("{name: 'name'}", "true");

    assertThat(
      results,
      contains(
        allOf(
          tupleWithValue("name", nullValue()),
          tupleWithValue("geom", nullValue())
        )
      )
    );
  }


  @Test
  public void intersectingFeatureIsReturnedWithNewGeometryEvenIfRhsIsDifferentCrs() {

    Geometry lhsGeometry = box(3000, 0000, 6000, 3500);
    // rhs is half as wide. so we should get a partial intersection (cut in half)
    Geometry rhsGeometry = box(4000, 1000, 5000, 3000);
    Geometry rhsGeometryInLatLong = latLongGeometryHelper.reproject(rhsGeometry);

    // this is bigger than the lhs - not testing the cutting here (really), just that the reprojection happens and that
    // the result goes back to the original crs
    rhsTuples.add(Tuple.ofValues(featureType, "box", rhsGeometryInLatLong));
    feature = Tuple.ofValues(featureType, "foo", lhsGeometry);

    evaluate("{}", "true");

    // should be the lhs feature, geometry is lat long, not nztm (like the index)
    assertThat(
      results,
      // two results - one for cut, one for difference
      contains(
        allOf(
          tupleWithValue("name", equalTo("foo")),
          // we put lat long in, expect lat long back
          tupleWithValue("geom", isInCrs(project.getSridSet(), nzGeometryHelper.getCrs()))
        ),
        allOf(
            tupleWithValue("name", equalTo("foo")),
            tupleWithValue("geom", isInCrs(project.getSridSet(), nzGeometryHelper.getCrs()))
          )

      )
    );
  }

  @Test
  public void intersectingLinesWithPolysShouldReturnCutLines() throws Exception {
    Geometry lhsGeometry = line(5, 0, 5, 10);

    Geometry rhsGeometry = box(1, 1, 9, 9);

    rhsTuples.add(Tuple.ofValues(featureType, "box", rhsGeometry));
    feature = Tuple.ofValues(featureType, "line", lhsGeometry);

    evaluate("{}", "true");

    assertThat(
        results,
        // two results - one for cut, one for difference
        containsInAnyOrder(
          // this part intersected the box
          tupleWithValue("geom", isGeometry(line(5, 1, 5, 9))),
          // these two are the non-intersecting 'difference'
          tupleWithValue("geom", isGeometry(nzGeometryHelper.line(5, 0, 5, 1))),
          tupleWithValue("geom", isGeometry(nzGeometryHelper.line(5, 9, 5, 10)
          ))
        )
      );
  }

  @Test
  public void willBreakASingleLineThatReentersArea() throws Exception {
    Geometry lhsGeometry = nzGeometryHelper.line(
        nzGeometryHelper.toCoordinate(20, 20),
        nzGeometryHelper.toCoordinate(20, 180),
        nzGeometryHelper.toCoordinate(80, 180),
        nzGeometryHelper.toCoordinate(80, 20)
    );

    rhsTuples.add(Tuple.ofValues(featureType, "box1", nzGeometryHelper.box(0, 0, 100, 100)));
    rhsTuples.add(Tuple.ofValues(featureType, "box2", nzGeometryHelper.box(0, 100, 100, 200)));
    feature = Tuple.ofValues(featureType, "line", lhsGeometry);

    evaluate("{}", "true");

    assertThat(results, containsInAnyOrder(
        // these part1 intersected box1
        tupleWithValue("geom", isGeometry(nzGeometryHelper.line(20, 20, 20, 100))),
        tupleWithValue("geom", isGeometry(nzGeometryHelper.line(80, 20, 80, 100))),
        // this part intersected box2
        tupleWithValue("geom", isGeometry(
            nzGeometryHelper.line(
                nzGeometryHelper.toCoordinate(20, 100),
                nzGeometryHelper.toCoordinate(20, 180),
                nzGeometryHelper.toCoordinate(80, 180),
                nzGeometryHelper.toCoordinate(80, 100))
        ))
    ));
  }

  @Test
  public void doesNotBreakUpAMultilineThatReentersArea() throws Exception {
    Geometry lhsGeometry = nzGeometryHelper.multiLine(
        nzGeometryHelper.line(20, 20, 20, 180),
        nzGeometryHelper.line(30, 80, 80, 80)
    );

    rhsTuples.add(Tuple.ofValues(featureType, "box1", nzGeometryHelper.box(0, 0, 100, 100)));
    rhsTuples.add(Tuple.ofValues(featureType, "box2", nzGeometryHelper.box(0, 100, 100, 200)));
    feature = Tuple.ofValues(featureType, "line", lhsGeometry);

    evaluate("{}", "true");

    assertThat(results, containsInAnyOrder(
        // this part intersected box1
        tupleWithValue("geom", isGeometry(nzGeometryHelper.multiLine(
            nzGeometryHelper.line(20, 20, 20, 100),
            nzGeometryHelper.line(30, 80, 80, 80)
        ))),
        // this part intersected box2
        tupleWithValue("geom", isGeometry(
            nzGeometryHelper.line(20, 100, 20, 180)
        ))
    ));
  }

  @Test
  public void canIntersectAMultilineThatCrossesAreas() throws Exception {
    Geometry lhsGeometry = nzGeometryHelper.multiLine(
        nzGeometryHelper.line(20, 20, 20, 80),
        nzGeometryHelper.line(80, 80, 80, 220)
    );

    rhsTuples.add(Tuple.ofValues(featureType, "box1", nzGeometryHelper.box(0, 0, 100, 100)));
    rhsTuples.add(Tuple.ofValues(featureType, "box2", nzGeometryHelper.box(0, 100, 100, 200)));

    feature = Tuple.ofValues(featureType, "line", lhsGeometry);

    evaluate("{}", "true");

    assertThat(results, contains(
        // this part intersected box1
        tupleWithValue("geom", isGeometry(nzGeometryHelper.multiLine(
            nzGeometryHelper.line(20, 20, 20, 80),
            nzGeometryHelper.line(80, 80, 80, 100)
        ))),
        // this part intersected box2
        tupleWithValue("geom", isGeometry(nzGeometryHelper.line(80, 100, 80, 200))),
        // and the difference
        tupleWithValue("geom", isGeometry(nzGeometryHelper.line(80, 200, 80, 220)))
    ));
  }

  @Test
  public void canIntersectAMultilineThatCrossesAreasInDifferenceCRS() throws Exception {
    Geometry lhsGeometry = nzGeometryHelper.multiLine(
        nzGeometryHelper.line(20, 20, 20, 80),
        nzGeometryHelper.line(80, 80, 80, 220)
    );

    ReferencedEnvelope rhsBounds = latLongGeometryHelper.reproject(nzGeometryHelper.envelope(
        nzGeometryHelper.toCoordinate(-100, -100),
        nzGeometryHelper.toCoordinate(1000, 1000)
    ));
    Struct rhsFeatureType = Struct.of("name", Types.TEXT,
        "geom", Referenced.of(Types.GEOMETRY, latLongGeometryHelper.getCrs(), rhsBounds));

    rhsTuples.add(Tuple.ofValues(rhsFeatureType, "box1",
        latLongGeometryHelper.reproject(nzGeometryHelper.box(0, 0, 100, 100))));
    rhsTuples.add(Tuple.ofValues(rhsFeatureType, "box2",
        latLongGeometryHelper.reproject(nzGeometryHelper.box(0, 100, 100, 200))));

    featureType = Struct.of("name", Types.TEXT,
        "geom", Referenced.of(Types.GEOMETRY, nzGeometryHelper.getCrs()));
    feature = Tuple.ofValues(featureType, "line", lhsGeometry);

    // this evaluates with all args specified
    evaluate("{}", "true");
    assertResultsWithReturnDifference();

    // this sets arg 3 to return_difference (should be fine to omit optional merge_attributes arg)
    evaluate("return_difference: true");
    assertResultsWithReturnDifference();
  }

  private void assertResultsWithReturnDifference() {
    // note the rounding tolerance is to allow for the reprojection of the RHS ->
    // nzmg.
    assertThat(results, contains(
        // this part intersected box1
        tupleWithValue("geom", geometryMatch(nzGeometryHelper.multiLine(
            nzGeometryHelper.line(20, 20, 20, 80),
            nzGeometryHelper.line(80, 80, 80, 100)), METER_TOLERANCE_NEAREST_CM)),
        // this part intersected box2
        tupleWithValue("geom", geometryMatch(nzGeometryHelper.line(80, 100, 80, 200), METER_TOLERANCE_NEAREST_CM)),
        // and the difference
        tupleWithValue("geom", geometryMatch(nzGeometryHelper.line(80, 200, 80, 220), METER_TOLERANCE_NEAREST_CM))
    ));
  }

  @Test
  public void thirdArgumentMustBeAStructDeclaration() throws Exception {
    tryToEvaluate("['foo', 'bar']");
    assertThat(
      realizationProblems,
      contains(Matchers.hasAncestorProblem(equalTo(TypeProblems.get().mismatch(
          parse("['foo', 'bar']"), StructDeclaration.class, ListDeclaration.class
      ))))
    );
  }

  @Test
  public void thirdArgumentMustBeAStructDeclarationWithStringConstants() throws Exception {
    tryToEvaluate("{name: feature.name}");
    assertThat(
      realizationProblems,
      contains(Matchers.hasAncestorProblem(equalTo(TypeProblems.get().mismatch(
          parse("feature.name"), Constant.class, PropertyAccess.class
      ))))
    );
  }

  @Test
  public void fourthArgumentMustBeABoolean() throws Exception {
    tryToEvaluate("{}", "1");
    assertThat(
      realizationProblems,
      contains(Matchers.hasAncestorProblem(equalTo(TypeProblems.get().mismatch(
          parse("1"), Nullable.BOOLEAN, Types.INTEGER
      ))))
    );
  }

  private void evaluate(String... extraArgs) {
    tryToEvaluate(extraArgs);
    assertTrue(realizationProblems.isEmpty());
  }

  /**
   * Realize and evaluate a call to layer_intersection using feature against everything in rhsTuples
   */
  private void tryToEvaluate(String... extraArgs) {
    ListRelation rhs = new ListRelation(rhsTuples.size() > 0 ? rhsTuples.get(0).getStruct() : featureType, rhsTuples);
    addPickledData("rhs", rhs);

    String extra = Arrays.asList(extraArgs).stream().collect(Collectors.joining(", "));

    if (extra.length() > 0) {
      extra = ", " + extra;
    }

    if (scopeType == null) {
      // delay building this to allow tests to change featuretype
      scopeType = featureType.parent("feature");
    }

    String expression = "layer_intersections(feature, bookmark('rhs')" + extra + ")";
    Tuple scope = Tuple.ofValues(scopeType, feature);
    results = (List<Tuple>) evaluate(expression, scope);
  }

}
