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


import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;

import com.google.common.collect.Range;

import nz.org.riskscape.engine.ArgsProblems;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.rl.ExpressionRealizer;
import nz.org.riskscape.engine.rl.RealizationContext;
import nz.org.riskscape.engine.rl.RealizedExpression;
import nz.org.riskscape.engine.types.GeomType;
import nz.org.riskscape.engine.types.Nullable;
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.eqrule.Coercer;
import nz.org.riskscape.engine.typeset.TypeSet;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.rl.ast.ExpressionProblems;
import nz.org.riskscape.rl.ast.FunctionCall;

/**
 * Note that a lot of these tests originally covered some of the functionality that is now tested off by the engine's
 * expressionrealizing tests since the realizeConstant method was put on there.  This could probably be rewritten to
 * do less mocking, but it's still got useful coverage without a rewrite
 *
 */
public class ArgumentListTest {

  ExpressionParser parser = ExpressionParser.INSTANCE;
  RealizationContext context = Mockito.mock(RealizationContext.class);
  ExpressionRealizer realizer = Mockito.mock(ExpressionRealizer.class);

  ArgumentList argumentList = ArgumentList.fromArray(
      new FunctionArgument("foo", Types.TEXT),
      new FunctionArgument("bar", Types.INTEGER)
  );

  @Before
  public void setupMocks() {
    TypeSet typeset = mock(TypeSet.class);
    // setup some simple type equivalence rules for testing
    when(typeset.isAssignable(any(), any())).thenAnswer(i -> {
      Type src = i.getArgument(0);
      Type dest = i.getArgument(1);
      if (src instanceof GeomType && dest.equals(Types.GEOMETRY)) {
        return true;
      } else {
        return src.equals(dest) || dest.equals(Types.ANYTHING);
      }
    });
    when(typeset.findEquivalenceCoercer(eq(Types.INTEGER), eq(Types.FLOATING)))
        .thenReturn(Optional.of(mock(Coercer.class)));

    Project project = mock(Project.class);
    when(project.getTypeSet()).thenReturn(typeset);
    when(context.getProject()).thenReturn(project);
    when(context.getExpressionRealizer()).thenReturn(realizer);
  }

  @Test
  public void evaluateConstantExtractsAConstantValueFromAConstantExpression() throws Exception {
    FunctionCall functionCall = parser.parse("foo('bar')").isA(FunctionCall.class).get();

    primeConstant("'bar'", "bar constant value", Types.TEXT);

    assertThat(
        argumentList.evaluateConstant(context, functionCall, "foo", String.class, Types.TEXT).get(),
        equalTo("bar constant value")
    );
  }

  @Test
  public void evaluateConstantExtractsAConstantValueFromSuperTypeGL581() throws Exception {
    // Some functions evaluate a constant with a super type of an allowed argument (possibly Object.class).
    // This is useful when the function can accept either A or B.
    // Calling with Object.class is used from the to_coverage function where there was a problem that the
    // to_coverage would build a new coverage on every call because evaluate constant did not work correctly.
    FunctionCall functionCall = parser.parse("foo('bar')").isA(FunctionCall.class).get();

    primeConstant("'bar'", "bar constant value", Types.TEXT);

    assertThat(
        argumentList.evaluateConstant(context, functionCall, "foo", Object.class, Types.ANYTHING).get(),
        equalTo("bar constant value")
    );
  }

  @Test
  public void evaluateConstantThrowsAProblemIfAPropertyAccessIsPresent() throws Exception {
    FunctionCall functionCall = parser.parse("foo(bar)").isA(FunctionCall.class).get();

    when(realizer.realizeConstant(parser.parse("bar"))).thenReturn(
        ResultOrProblems.failed(ExpressionProblems.get().constantRequired(parser.parse("bar"))));

    assertThat(
        argumentList.get("foo").evaluateConstant(context, functionCall, String.class).getProblems(),
      contains(equalTo(
          Problems.foundWith(argumentList.get("foo"),
              ExpressionProblems.get().constantRequired(parser.parse("bar")))
    )));
  }


  @Test
  public void evaluateConstantThrowsOriginalProblemsIfOtherExpressionIssues() throws Exception {
    FunctionCall functionCall = parser.parse("foo(bar)").isA(FunctionCall.class).get();

    Problem raised = ExpressionProblems.get().noSuchOperatorFunction(
        "foo", Arrays.asList(Types.TEXT, Types.INTEGER));

    when(realizer.realizeConstant(parser.parse("bar"))).thenReturn(
        // this is not a property access problem, so doesn't indicate a reference to something variable
        ResultOrProblems.failed(raised));

    assertThat(
      argumentList.get("foo").evaluateConstant(context, functionCall, String.class).getProblems(),
      contains(is(Problems.foundWith(argumentList.get("foo"), raised)))
    );
  }

  @Test
  public void evaluateConstantThrowsAProblemIfTypeDoesNotMatch() throws Exception {
    FunctionCall functionCall = parser.parse("foo(1)").isA(FunctionCall.class).get();
    primeConstant("1", 1L, Types.INTEGER);

    assertThat(
        argumentList.get("foo").evaluateConstant(context, functionCall, String.class).getProblems(),
      contains(equalTo(Problems.foundWith(argumentList.get("foo"),
          TypeProblems.get().mismatch(
            functionCall.getArguments().get(0).getExpression(), Types.TEXT, Types.INTEGER))
    )));
  }

  @Test
  public void evaluateConstantHandlesOutOfOrderArgumentsGL393() throws Exception {
    // note these are out of order
    // also in this example the expressed values don't match the realized results - we're using mocks - it's
    // the realized result's signature that matters
    FunctionCall functionCall = parser.parse("funky(bar: 2, foo: 'cool')").isA(FunctionCall.class).get();

    primeConstant("2", "bar-arg constant value", Types.TEXT);
    primeConstant("'cool'", "foo-arg constant value", Types.TEXT);

    assertThat(
        argumentList.evaluateConstant(context, functionCall, "foo", String.class, Types.TEXT).get(),
        equalTo("foo-arg constant value")
    );

    assertThat(
        argumentList.evaluateConstant(context, functionCall, "bar", String.class, Types.TEXT).get(),
        equalTo("bar-arg constant value")
    );
  }

  @Test
  public void evaluateConstantHandlesOutOfOrderArgumentsWithMissingEarlierArgs() throws Exception {
    FunctionCall functionCall = parser.parse("funky(bar: 2)").isA(FunctionCall.class).get();

    primeConstant("2", "bar-arg constant value", Types.TEXT);
    primeConstant("'cool'", "foo-arg constant value", Types.TEXT);

    assertThat(
        argumentList.evaluateConstant(context, functionCall, "bar", String.class, Types.TEXT).get(),
        equalTo("bar-arg constant value")
    );

    assertTrue(
        argumentList.evaluateConstant(context, functionCall, "foo", String.class, Types.TEXT).hasErrors()
    );
  }

  @Test
  public void canAppendArgumentAndGetNewArgumentList() {
    // sanity check it up front
    assertThat(argumentList, hasSize(2));

    ArgumentList newArgList = argumentList.withExtraArgument("baz", Types.TEXT);
    assertThat(newArgList, contains(
        hasProperty("keyword", is("foo")),
        hasProperty("keyword", is("bar")),
        allOf(
            hasProperty("keyword", is("baz")),
            hasProperty("type", is(Types.TEXT))
        )
    ));

    // ensure that source list hasn't grown
    assertThat(argumentList, hasSize(2));

    newArgList = argumentList.withExtraArgument(
        new FunctionArgument("baz", Types.TEXT), new FunctionArgument("bazza", Types.INTEGER));
    assertThat(newArgList, contains(
        hasProperty("keyword", is("foo")),
        hasProperty("keyword", is("bar")),
        allOf(
            hasProperty("keyword", is("baz")),
            hasProperty("type", is(Types.TEXT))
        ),
        allOf(
            hasProperty("keyword", is("bazza")),
            hasProperty("type", is(Types.INTEGER))
        )
    ));

    // ensure that source list hasn't grown
    assertThat(argumentList, hasSize(2));
  }

  @Test
  public void optionalNullableTypesReflectedInArity() throws Exception {
    assertThat(argumentList.getArity(), is(Range.singleton(2)));

    // last arg is nullable, so can either accept 2 or 3 args
    ArgumentList oneNull = ArgumentList.create("a", Types.TEXT, "b", Types.INTEGER, "c", Nullable.TEXT);
    assertThat(oneNull.getArity(), is(Range.closed(2, 3)));
    assertTrue(oneNull.getArity().contains(2));
    assertTrue(oneNull.getArity().contains(3));
    assertFalse(oneNull.getArity().contains(1));
    assertFalse(oneNull.getArity().contains(4));

    // last arg is nullable, so can either accept 2 or 3 args
    ArgumentList twoNulls = ArgumentList.create("a", Types.TEXT, "b", Nullable.INTEGER, "c", Nullable.TEXT);
    assertThat(twoNulls.getArity(), is(Range.closed(1, 3)));
    assertTrue(twoNulls.getArity().contains(1));
    assertTrue(twoNulls.getArity().contains(2));
    assertTrue(twoNulls.getArity().contains(3));
    assertFalse(twoNulls.getArity().contains(0));
    assertFalse(twoNulls.getArity().contains(4));

    // can only omit args off the end
    ArgumentList lastArgNotNull = ArgumentList.create("a", Nullable.TEXT, "b", Nullable.INTEGER, "c", Types.TEXT);
    assertThat(lastArgNotNull.getArity(), is(Range.singleton(3)));
    assertTrue(lastArgNotNull.getArity().contains(3));
    assertFalse(lastArgNotNull.getArity().contains(2));
    assertFalse(lastArgNotNull.getArity().contains(1));
  }

  @Test
  public void canCheckBasicArgumentTypesAreCompatible() throws Exception {
    assertTrue(getCompatibility(argumentList, Types.TEXT, Types.INTEGER));
    assertFalse(getCompatibility(argumentList, Types.INTEGER, Types.TEXT));
    assertFalse(getCompatibility(argumentList, Types.TEXT));
    assertFalse(getCompatibility(argumentList));
    assertFalse(getCompatibility(argumentList, Types.TEXT, Types.INTEGER, Types.FLOATING));

    // types are assignable, i.e. the expected type is a common ancesto
    ArgumentList assignableArgs = ArgumentList.create("foo", Types.GEOMETRY, "bar", Types.ANYTHING,
        "baz", Types.GEOMETRY);
    assertTrue(getCompatibility(assignableArgs, Types.POLYGON, Types.INTEGER, Types.GEOMETRY));
    assertTrue(getCompatibility(assignableArgs, Types.GEOMETRY, Types.INTEGER, Types.POINT));
    assertFalse(getCompatibility(assignableArgs, Types.TEXT, Types.INTEGER, Types.GEOMETRY));
    assertFalse(getCompatibility(assignableArgs, Types.GEOMETRY, Types.INTEGER, Types.TEXT));
    assertFalse(getCompatibility(assignableArgs, Types.GEOMETRY, Types.INTEGER));

    // nullable types that can be omitted altogether
    ArgumentList omittableArgs = ArgumentList.create("foo", Types.GEOMETRY, "bar", Nullable.TEXT,
        "baz", Nullable.INTEGER);
    assertTrue(getCompatibility(omittableArgs, Types.POLYGON, Nullable.TEXT, Nullable.INTEGER));
    assertTrue(getCompatibility(omittableArgs, Types.LINE, Nullable.TEXT));
    assertTrue(getCompatibility(omittableArgs, Types.POINT));
    assertFalse(getCompatibility(omittableArgs));
    assertFalse(getCompatibility(omittableArgs, Types.GEOMETRY, Nullable.TEXT, Nullable.INTEGER, Nullable.TEXT));
    assertFalse(getCompatibility(omittableArgs, Types.TEXT, Nullable.TEXT, Nullable.INTEGER));
    assertFalse(getCompatibility(omittableArgs, Types.GEOMETRY, Nullable.ANYTHING, Nullable.INTEGER));
  }

  @Test
  public void canReportProblemsWithArgumentTypes() throws Exception {
    ArgumentList args = ArgumentList.create("foo", Types.GEOMETRY, "bar", Types.FLOATING, "baz",
        Nullable.INTEGER);

    assertThat(getCompatibilityProblems(args, Types.TEXT), contains(
        ArgsProblems.get().wrongNumber(Range.closed(2, 3), 1)));
    assertThat(getCompatibilityProblems(args, Types.GEOMETRY, Types.FLOATING, Types.INTEGER, Types.TEXT),
        contains(
            ArgsProblems.get().wrongNumber(Range.closed(2, 3), 4)
        ));

    // can report multiple type mismatches
    assertThat(getCompatibilityProblems(args, Types.TEXT, Types.FLOATING, Types.FLOATING),
        contains(
            ArgsProblems.mismatch(args.get(0), Types.TEXT),
            ArgsProblems.mismatch(args.get(2), Types.FLOATING)
        ));

    // we shouldn't report a type mismatch for assignable args
    assertThat(getCompatibilityProblems(args, Types.POLYGON, Types.FLOATING, Types.TEXT),
        contains(
            ArgsProblems.mismatch(args.get(2), Types.TEXT)
        ));

    // we shouldn't report a type mismatch for coercible args (it's usually a red herring)
    assertThat(getCompatibilityProblems(args, Types.POLYGON, Types.INTEGER, Types.TEXT),
        contains(
            ArgsProblems.mismatch(args.get(2), Types.TEXT)
        ));

    // we can't realize with coercible args though, so should still report some kind of error
    // Note that coercion is usually handled separately (i.e. in DefaultFunctionResolver) to the error handling
    assertThat(getCompatibilityProblems(args, Types.POLYGON, Types.INTEGER),
        contains(
            ArgsProblems.get().realizableDidNotMatch(RiskscapeFunction.class,
                Arrays.asList(Types.POLYGON, Types.INTEGER))
        ));
  }

  @Test
  public void canOverrideProblemReportingForArgumentTypeMismatches() throws Exception {
    // this lets us drill into more detail or add tips for specific args
    assertThat(argumentList.getProblems(context, Arrays.asList(Types.TEXT, Types.TEXT),
            (arg, type) -> Arrays.asList(Problem.error("Not %s! It's a %s!", type.toString(), arg.getKeyword()))),
        contains(
            Problem.error("Not %s! It's a %s!", "Text", "bar")
        ));

    // can control whether or not we report a bad type, e.g. only tell the user about the first failure
    assertThat(argumentList.getProblems(context, Arrays.asList(Types.ANYTHING, Types.ANYTHING),
            (arg, type) -> arg.getKeyword().equals("foo") ?
                Arrays.asList(Problem.error("Bad foo!")) : Collections.emptyList()),
          contains(
              Problem.error("Bad foo!")
          ));
  }

  @Test
  public void canCreateAnArgumentListFromAnonymousTypes() throws Exception {
    ArgumentList list = ArgumentList.anonymous(List.of(Types.TEXT, Types.INTEGER));

    assertThat(list, contains(
        allOf(
            hasProperty("index", equalTo(0)),
            hasProperty("anonymous", is(true)),
            // keyword is 'generated'
            hasProperty("keyword", equalTo("arg0"))
        ),
        allOf(
            hasProperty("index", equalTo(1)),
            hasProperty("anonymous", is(true)),
            hasProperty("keyword", equalTo("arg1"))
        )
    ));
  }

  @Test
  public void canCreateAnArgumentListFromGivenFunctionArguments() throws Exception {
    FunctionArgument fooArg = new FunctionArgument("foo", Types.TEXT);
    FunctionArgument barArg = new FunctionArgument("bar", Types.INTEGER);

    ArgumentList list = new ArgumentList(List.of(
        fooArg,
        barArg
    ));

    assertThat(list.get("foo"), sameInstance(fooArg));
    assertThat(fooArg.getIndex(), equalTo(0));
    assertThat(list.get("bar"), sameInstance(barArg));
    assertThat(barArg.getIndex(), equalTo(1));
  }

  @Test(expected = IllegalStateException.class)
  public void canNotCreateAnArgumentListFromAlreadyIndexedArgs() throws Exception {
    ArgumentList existingList = ArgumentList.anonymous(List.of(Types.TEXT, Types.INTEGER));
    // note how the indices are swapped - rightly or wrongly, I've allowed reuse where there would be no impact to the
    // immutable-ness of the index, so it's not enough to reuse args, they must also be different.
    new ArgumentList(List.of(existingList.get(1), existingList.get(0)));
  }

  private List<Problem> getCompatibilityProblems(ArgumentList args, Type... givenTypes) {
    boolean isCompatible = args.isCompatible(context, Arrays.asList(givenTypes));
    List<Problem> problems = args.getProblems(context, Arrays.asList(givenTypes));
    assertEquals(isCompatible, problems.isEmpty());
    return problems;
  }

  private boolean getCompatibility(ArgumentList args, Type... givenTypes) {
    return getCompatibilityProblems(args, givenTypes).isEmpty();
  }

  private void primeConstant(String expression, Object constantValue, Type resultType) {
    RealizedExpression expr = Mockito.mock(RealizedExpression.class);

    when(expr.getResultType()).thenReturn(resultType);
    when(expr.evaluate(Tuple.EMPTY_TUPLE)).thenReturn(constantValue);
    when(expr.evaluateConstant()).thenReturn(constantValue);
    when(expr.isConstant()).thenReturn(true);

    when(realizer.realizeConstant(parser.parse(expression))).thenReturn(ResultOrProblems.of(expr));
  }
}
