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

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

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.junit.Test;
import org.locationtech.jts.geom.Point;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;

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

import nz.org.riskscape.engine.Assert;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.projection.TypeProjection.ProblemCodes;
import nz.org.riskscape.engine.relation.InvalidTupleException;
import nz.org.riskscape.engine.relation.ListRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.relation.TupleIterator;
import nz.org.riskscape.engine.relation.TypeCheckingOptions;
import nz.org.riskscape.engine.rl.DefaultOperators;
import nz.org.riskscape.engine.rl.MathsFunctions;
import nz.org.riskscape.engine.types.Enumeration;
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.Struct.StructMember;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.types.WithinRange;
import nz.org.riskscape.engine.types.WithinSet;
import nz.org.riskscape.engine.typeset.TypeSet;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.ExpressionProblems;
import nz.org.riskscape.rl.ast.PropertyAccess;

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

  TypeSet typeSet = new TypeSet();
  Struct sourceType = Struct.of("foo", Types.TEXT).and("baz", Types.TEXT);
  Struct targetType;

  public TypeProjectionTest() {
    project.getFunctionSet().insertFirst(DefaultOperators.INSTANCE);
    project.getFunctionSet().addAll(MathsFunctions.FUNCTIONS);
  }

  @Test
  public void testCanApplyAnEquivalentType() {
    targetType = Struct.of("foo", Types.TEXT).and("baz", Types.TEXT);

    ListRelation source = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "bar1", 1),
        Tuple.ofValues(sourceType, "bar2", "2"));

    Relation relation = source.project(new TypeProjection(targetType,
        ImmutableMap.of(), EnumSet.noneOf(TypeCheckingOptions.class),
        realizationContext)).get();

    TupleIterator coerced = relation.iterator();
    List<Tuple> collected = Lists.newArrayList();
    while (coerced.hasNext()) {
      collected.add(coerced.next());
    }

    assertSame(targetType, collected.get(0).getStruct());
    assertSame(targetType, collected.get(1).getStruct());

    List<Tuple> expected = Lists.newArrayList(
        Tuple.ofValues(targetType, "bar1", 1),
        Tuple.ofValues(targetType, "bar2", "2"));

    assertEquals(expected, collected);
  }

  @Test
  public void canCoerceValues() {
    targetType = Struct.of("foo", Types.TEXT).and("baz", Types.INTEGER);

    ListRelation source = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "bar1", 1),
        Tuple.ofValues(sourceType, "bar2", "2"),
        Tuple.ofValues(sourceType, "bar3", new BigDecimal("3")),
        Tuple.ofValues(sourceType, "bar4", "4.55"));

    Relation projected = source.project(new TypeProjection(targetType,
        ImmutableMap.of(), EnumSet.of(TypeCheckingOptions.COERCE),
        realizationContext)).get();

    List<Tuple> collected = collect(projected);

    assertSame(targetType, collected.get(0).getStruct());
    assertSame(targetType, collected.get(1).getStruct());

    List<Tuple> expected = Lists.newArrayList(
        Tuple.ofValues(targetType, "bar1", 1L),
        Tuple.ofValues(targetType, "bar2", 2L),
        Tuple.ofValues(targetType, "bar3", 3L),
        Tuple.ofValues(targetType, "bar4", 4L));

    assertEquals(expected, collected);
  }

  @Test
  public void canMakePropertiesNullable() {
    targetType = Struct.of("Geometry", Types.GEOMETRY, "foo", Nullable.TEXT, "bar", Nullable.INTEGER);

    Struct mySourceType = Struct.of("Geometry", Types.GEOMETRY, "foo", Types.TEXT, "bar", Types.INTEGER);

    Point point = mock(Point.class);
    ListRelation source = new ListRelation(mySourceType,
        Tuple.ofValues(mySourceType, point, "foo", 10L),
        Tuple.ofValues(mySourceType, point, null, 10L),
        Tuple.ofValues(mySourceType, point, "foo", null),
        Tuple.ofValues(mySourceType, point, null)
    );

    Relation projected = source.project(new TypeProjection(targetType,
        ImmutableMap.of(), EnumSet.of(TypeCheckingOptions.SKIP_INVALID_TUPLES, TypeCheckingOptions.COERCE),
        realizationContext)).get();

    List<Tuple> collected = collect(projected);

    assertSame(targetType, collected.get(0).getStruct());
    assertSame(targetType, collected.get(1).getStruct());

    List<Tuple> expected = Lists.newArrayList(
        Tuple.ofValues(targetType, point, "foo", 10L),
        Tuple.ofValues(targetType, point, null, 10L),
        Tuple.ofValues(targetType, point, "foo", null),
        Tuple.ofValues(targetType, point, null)
    );

    assertEquals(expected, collected);
  }

  @Test
  public void testCanApplyANarrowerType() {
    targetType = Struct.of("foo", Types.TEXT);

    ListRelation source = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "bar1", 1),
        Tuple.ofValues(sourceType, "bar2", "2"));

    Relation projected = source.project(new TypeProjection(targetType, ImmutableMap.of(),
        EnumSet.noneOf(TypeCheckingOptions.class),
        realizationContext)).get();
    List<Tuple> collected = collect(projected);

    assertSame(targetType, collected.get(0).getStruct());
    assertSame(targetType, collected.get(1).getStruct());

    List<Tuple> expected = Lists.newArrayList(
        Tuple.ofValues(targetType, "bar1"),
        Tuple.ofValues(targetType, "bar2"));

    assertEquals(expected, collected);
  }

  @Test
  public void testCanApplyABroaderTypeWhereDisjointMembersAreNullable() {
    targetType = Struct.of("foo", Types.TEXT).and("bar", Nullable.INTEGER);

    ListRelation source = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "bar1"),
        Tuple.ofValues(sourceType, "bar2"));

    Relation projected = source.project(new TypeProjection(targetType, ImmutableMap.of(),
        EnumSet.noneOf(TypeCheckingOptions.class),
        realizationContext)).get();
    List<Tuple> collected = collect(projected);

    assertSame(targetType, collected.get(0).getStruct());
    assertSame(targetType, collected.get(1).getStruct());

    List<Tuple> expected = Lists.newArrayList(
        Tuple.ofValues(targetType, "bar1", null),
        Tuple.ofValues(targetType, "bar2", null));

    assertEquals(expected, collected);
  }

  @Test
  public void willFailOnUnCoerceableValues() {
    targetType = Struct.of("foo", Types.TEXT).and("baz", Types.INTEGER);

    ListRelation source = new ListRelation(sourceType,
        Tuple.ofValues(sourceType, "bar1", 1),
        Tuple.ofValues(sourceType, "bar2", "2"),
        Tuple.ofValues(sourceType, "bar3", "three"),
        Tuple.ofValues(sourceType, "bar4", "4.55"));

    TypeProjection projection= new TypeProjection(targetType, realizationContext);

    TupleIterator coerced = source.project(projection).get().iterator();
    coerced.next();
    coerced.next();
    InvalidTupleException ex = Assert.assertThrows(InvalidTupleException.class, () -> coerced.next());
    assertSame(source.getList().get(2), ex.getTuple());
    coerced.next();
  }

  @Test
  public void willFailToMapOnMissingMembers() {
    targetType = Struct.of("foos", Types.TEXT, "baz", Types.INTEGER);

    assertThat(new ListRelation(sourceType).project(new TypeProjection(targetType, realizationContext)).getProblems(),
        contains(
            isProblem(Severity.ERROR, ProblemCodes.MISSING_MEMBER)
        ));
  }

  @Test
  public void canRenameAttributes() throws Exception {
    targetType = Struct.of("foo", Types.INTEGER, "borez", Types.TEXT);

    Map<String, Expression> renames = ImmutableMap.of(
        "borez", PropertyAccess.of("baz"));

    ListRelation source = new ListRelation(sourceType,
        Tuple.of(sourceType, "foo", "5", "baz", "10"),
        Tuple.of(sourceType, "foo", "10", "baz", "20"));

    TypeProjection projection = new TypeProjection(
        targetType,
        renames,
        realizationContext);

    List<Tuple> expected = Lists.newArrayList(
        Tuple.of(targetType, "foo", 5L, "borez", "10"),
        Tuple.of(targetType, "foo", 10L, "borez", "20")
    );

    List<Tuple> collect = collect(source.project(projection).get());
    assertEquals(expected, collect);
  }

  @Test
  public void canApplyASimpleMathsFunctionToAttributes() throws Exception {
    Struct mySourceType = Struct.of("foo", Types.INTEGER).and("baz", Types.INTEGER);
    targetType = Struct.of("foo", Types.INTEGER);

    Map<String, Expression> renames = ImmutableMap.of(
        "foo", toExpression("foo * 10"));

    ListRelation source = new ListRelation(mySourceType,
        Tuple.of(mySourceType, "foo", 5L, "baz", 10L),
        Tuple.of(mySourceType, "foo", 10L, "baz", 20L));

    TypeProjection projection = new TypeProjection(
        targetType,
        renames,
        realizationContext);

    List<Tuple> expected = Lists.newArrayList(
        Tuple.of(targetType, "foo", 50L),
        Tuple.of(targetType, "foo", 100L)
    );

    List<Tuple> collect = collect(source.project(projection).get());
    assertEquals(expected, collect);
  }

  @Test
  public void canTraceAttributesViaProjectorInterface() throws Exception {
    targetType = Struct.of("foo", Types.INTEGER, "baz", Types.TEXT);

    TypeProjection projection = new TypeProjection(
        targetType, realizationContext);

    Map<List<StructMember>, List<StructMember>> directMapping =
        projection.getProjectionFunction(sourceType).get().getDirectMapping();

    assertEquals(Arrays.asList(sourceType.getEntry("foo")),
        directMapping.get(Arrays.asList(targetType.getEntry("foo"))));

    assertEquals(Arrays.asList(sourceType.getEntry("baz")),
        directMapping.get(Arrays.asList(targetType.getEntry("baz"))));

  }

  @Test
  public void canTraceMappedAttributesViaProjectorInterface() throws Exception {
    targetType = Struct.of("foo-mapped", Types.INTEGER, "baz", Types.FLOATING);

    TypeProjection projection = new TypeProjection(
        targetType, ImmutableMap.of(
            "foo-mapped", PropertyAccess.of("foo"),
            "baz", toExpression("int(foo) * 10")),
        realizationContext);

    Map<List<StructMember>, List<StructMember>> directMapping =
        projection.getProjectionFunction(sourceType).getWithProblemsIgnored().getDirectMapping();

    assertEquals(Arrays.asList(sourceType.getEntry("foo")),
        directMapping.get(Arrays.asList(targetType.getEntry("foo-mapped"))));

    assertNull(directMapping.get(Arrays.asList(targetType.getEntry("baz"))));
  }

  @Test
  public void willKeepGeometryWrappers() throws Exception {
    targetType = Struct.of("geom", Types.GEOMETRY, "baz", Types.TEXT);

    TypeProjection projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct mySourceType = Struct.of("geom", Types.POINT, "baz", Types.TEXT);
    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(mySourceType));

    Type referencedPoint = Referenced.of(Types.POINT, mock(CoordinateReferenceSystem.class), null);
    mySourceType = Struct.of("geom", referencedPoint, "baz", Types.TEXT);

    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(mySourceType));

    targetType = Struct.of("geom", Types.GEOMETRY, "baz", Types.INTEGER);
    projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct expectedTargetType = Struct.of("geom", referencedPoint, "baz", Types.INTEGER);
    Projector projector = projection.getProjectionFunction(mySourceType).get();
    assertThat(projector, projecting(expectedTargetType));

    // and now we test the actual projection to make sure there are no not my struct errors.
    Point point = mock(Point.class);
    assertThat(projector.apply(Tuple.ofValues(mySourceType, point, "10")),
        is(Tuple.ofValues(expectedTargetType, point, 10L)));
  }

  @Test
  public void willKeepGeometryWrappersWhenGeometryIsMadeNullable() throws Exception {
    targetType = Struct.of("geom", Nullable.GEOMETRY, "baz", Types.TEXT);

    TypeProjection projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct mySourceType = Struct.of("geom", Types.POINT, "baz", Types.TEXT);
    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(Struct.of("geom", Nullable.of(Types.POINT), "baz", Types.TEXT)));

    Type referencedPoint = Referenced.of(Types.POINT, mock(CoordinateReferenceSystem.class), null);
    mySourceType = Struct.of("geom", referencedPoint, "baz", Types.TEXT);

    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(Struct.of("geom", Nullable.of(referencedPoint), "baz", Types.TEXT)));

    targetType = Struct.of("geom", Nullable.GEOMETRY, "baz", Types.INTEGER);
    projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct expectedTargetType = Struct.of("geom", Nullable.of(referencedPoint), "baz", Types.INTEGER);
    Projector projector = projection.getProjectionFunction(mySourceType).get();
    assertThat(projector, projecting(expectedTargetType));

    // and now we test the actual projection to make sure there are no not my struct errors.
    Point point = mock(Point.class);
    assertThat(projector.apply(Tuple.ofValues(mySourceType, point, "10")),
        is(Tuple.ofValues(expectedTargetType, point, 10L)));
  }

  @Test
  public void willKeepWrappedTypes() throws Exception {
    // if the source type contains wrapped types that are assignable to the target type then the wrapped
    // types should be kept in the produced type.
    targetType = Struct.of("foo", Types.INTEGER, "baz", Types.TEXT);

    TypeProjection projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct mySourceType = Struct.of("foo", new WithinSet(Types.INTEGER, 1L, 3L, 5L), "baz", Types.TEXT);
    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(mySourceType));

    mySourceType = Struct.of("foo", new WithinRange(Types.INTEGER, 0L, 10L), "baz", Types.TEXT);

    assertThat(projection.getProjectionFunction(mySourceType).get(),
        projecting(mySourceType));

    targetType = Struct.of("foo", Types.INTEGER, "baz", Types.INTEGER);
    projection = new TypeProjection(
        targetType, Collections.emptyMap(),
        realizationContext);

    Struct expectedTargetType = Struct.of("foo", new WithinRange(Types.INTEGER, 0L, 10L), "baz", Types.INTEGER);
    Projector projector = projection.getProjectionFunction(mySourceType).get();
    assertThat(projector, projecting(expectedTargetType));

    // and now we test the actual projection to make sure there are no not my struct errors.
    assertThat(projector.apply(Tuple.ofValues(mySourceType, 3L, "10")),
        is(Tuple.ofValues(expectedTargetType, 3L, 10L)));
  }

  @Test
  public void badMappingExpressionWillNotProject() throws Exception {
    targetType = Struct.of("foo-mapped", Types.INTEGER, "baz", Types.INTEGER);

    TypeProjection projection = new TypeProjection(
        targetType, ImmutableMap.of(
            "foo-mapped", PropertyAccess.of("foo"),
            "baz", toExpression("int(ba)")),
        realizationContext);

    assertThat(projection.getProjectionFunction(sourceType), failedResult(hasAncestorProblem(
        equalTo(ExpressionProblems.get().noSuchStructMember("ba", Arrays.asList("foo", "baz")))
    )));
  }

  @Test
  public void onlyIncludesWhitelistedSimpleTypesInDirectMapping() throws Exception {
    Enumeration anEnum = Enumeration.oneBased("foo", "bar", "baz");
    targetType = Struct.of("foo", anEnum, "baz", Types.FLOATING);

    TypeProjection projection = new TypeProjection(
        targetType, realizationContext);

    Map<String, String> mappingStrings = projection.getProjectionFunction(sourceType).get().getDirectMappingStrings();

    assertTrue(mappingStrings.containsKey("baz"));
    assertFalse(mappingStrings.containsKey("foo"));
  }

  @Test
  public void mappingToUnknownAttributeMakesProblem() throws Exception {
    targetType = Struct.of("foo", Types.INTEGER, "baz", Types.TEXT);

    TypeProjection projection = new TypeProjection(
        targetType, ImmutableMap.of(
            "foo", toExpression("9"),
            "baz", toExpression("'bazza'"),
            "foo-baz", toExpression("'foo'")),
        realizationContext);

    ResultOrProblems<Projector> result = projection.getProjectionFunction(Types.ANYTHING.asStruct());
    assertThat(result.getProblems(), contains(
        isProblem(Severity.ERROR, ProblemCodes.MISSING_TARGET)));
  }

  private List<Tuple> collect(Relation relation) {
    return relation.stream().collect(Collectors.toList());
  }

  private Expression toExpression(String expr) {
    return expressionParser.parse(expr);
  }

}
