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

import static nz.org.riskscape.engine.Assert.*;
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.Optional;

import org.junit.Test;

import com.google.common.collect.Lists;

import nz.org.riskscape.engine.Matchers;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.bind.ParameterField;
import nz.org.riskscape.engine.bind.impl.NumberBinder;
import nz.org.riskscape.engine.function.ArgumentList;
import nz.org.riskscape.engine.function.FunctionArgument;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.types.RSList;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.types.WithinSet;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.ExpressionProblems;
import nz.org.riskscape.rl.ast.FunctionCall;

public class FunctionCallOptionsTest extends ProjectTest {

  public enum OptionEnum {
    SOME_VALUE,
    ON,
    OFF
  }

  public static class Options {

    @ParameterField
    String required;

    @ParameterField
    Optional<String> text = Optional.of("default");

    @ParameterField
    Optional<Integer> number = Optional.of(42);

    @ParameterField
    Optional<Double> floating = Optional.of(42D);

    @ParameterField
    Optional<Object> anything = Optional.empty();

    @ParameterField
    Optional<OptionEnum> optionEnum;

    @ParameterField
    String myField = "foo";
  }

  public static class ListOptions {
    @ParameterField
    List<String> strings;

    @ParameterField
    List<OptionEnum> enums = new ArrayList<>();
  }

  ArgumentList argumentList = ArgumentList.fromArray(
      FunctionCallOptions.options(Options.class)
  );

  @Test
  public void canBuildStructFromOptions() {
    Struct optStruct = FunctionCallOptions.optionsToStruct(Options.class);

    Struct expected = Struct.of("required", Types.TEXT, "text", Types.TEXT,
        "number", Types.INTEGER, "floating", Types.FLOATING)
        .add("anything", Types.ANYTHING)
        .add("option_enum", WithinSet.fromEnum(OptionEnum.class))
        .add("my_field", Types.TEXT);
    assertThat(optStruct, is(expected));
  }

  @Test
  public void canSetAllOptionValues() {
    Options opts = bindOptions("""
        {
          anything: 99,
          required: 'foo',
          text: 'baz',
          number: 99,
          floating: 3,
          option_enum: 'some_value',
          my_field: 'bar'
        }
        """
    ).get();

    assertThat(opts.anything.get(), is(99L));
    assertThat(opts.required, is("foo"));
    assertThat(opts.text.get(), is("baz"));
    assertThat(opts.number.get(), is(99));
    assertThat(opts.floating.get(), is(3D));
    assertThat(opts.optionEnum.get(), is(OptionEnum.SOME_VALUE));
    assertThat(opts.myField, is("bar"));
  }

  @Test
  public void canGetDefaultOptions() {
    Options opts = bindOptions("{required: 'foo'}").get();

    assertThat(opts.anything.isEmpty(), is(true));
    assertThat(opts.required, is("foo"));
    assertThat(opts.text.get(), is("default"));
    assertThat(opts.number.get(), is(42));
    assertThat(opts.optionEnum.orElse(null), nullValue());
  }

  @Test
  public void canGetOptions() {
    Options opts = bindOptions("{required: 'foo', text: 'foo', number: 33, option_enum: 'on'}").get();

    assertThat(opts.text.get(), is("foo"));
    assertThat(opts.number.get(), is(33));
    assertThat(opts.optionEnum.get(), is(OptionEnum.ON));
  }

  @Test
  public void canSetEnums() {
    Options opts = bindOptions("{option_enum: 'some_value', required: 'foo'}").get();
    assertThat(opts.optionEnum.get(), is(OptionEnum.SOME_VALUE));

    opts = bindOptions("{option_enum: 'SOME_VALUE', required: 'foo'}").get();
    assertThat(opts.optionEnum.get(), is(OptionEnum.SOME_VALUE));

    opts = bindOptions("{option_enum: 'oN', required: 'foo'}").get();
    assertThat(opts.optionEnum.get(), is(OptionEnum.ON));
  }

  @Test
  public void canSetAnyThing() {
    // when setting anything we'll need to use bindOptionsTuple to know what type was actually bound.
    Tuple opts = bindTuple("{anything: 5.5}").get().get();

    assertThat(opts.fetch("anything"), is(5.5D));
    assertThat(opts.getStruct().getEntry("anything").getType(), is(Types.FLOATING));

    opts = bindTuple("{anything: {foo: 'bar'}}").get().get();

    Tuple expected = tuple("{foo: 'bar'}");
    assertThat(opts.fetch("anything"), equalTo(expected));
    assertThat(opts.getStruct().getEntry("anything").getType(), equalTo(expected.getStruct()));
  }

  @Test
  public void bindingListToStringCouldBeArityError() {
    assertThat(bindOptions("{required: ['foo', 'bar']}"),
        Matchers.failedResult(is(ParamProblems.get().wrongNumberGiven("required", "1", 2))));

    // you can use a single item list and get away with it.
    Options opts = bindOptions("{required: ['foo']}").get();
    assertThat(opts.required, is("foo"));
  }

  @Test
  public void canHandleListOptions() {
    assertThat(FunctionCallOptions.optionsToStruct(ListOptions.class),
        is(Struct.of("strings", RSList.create(Types.TEXT),
            "enums", RSList.create(WithinSet.fromEnum(OptionEnum.class))
        ))
    );

    ListOptions opts = bindOptions("{strings: ['foo', 'bar']}", ListOptions.class).get();
    assertThat(opts.strings, contains(
        "foo",
        "bar"
    ));
    assertThat(opts.enums, hasSize(0));

    opts = bindOptions("{strings: 'bar', enums: 'some_value'}", ListOptions.class).get();
    assertThat(opts.strings, contains(
        "bar"
    ));
    assertThat(opts.enums, contains(
        OptionEnum.SOME_VALUE
    ));
  }

  @Test
  public void canAssignArbitaryNameToOptionsArg() {
    FunctionArgument optionArgWithCustomName = FunctionCallOptions.options("my_options", Options.class);
    assertThat(optionArgWithCustomName.getKeyword(), is("my_options"));

    Struct expectedType = Struct.of("required", Types.TEXT, "text", Types.TEXT,
        "number", Types.INTEGER, "floating", Types.FLOATING)
        .add("anything", Types.ANYTHING)
        .add("option_enum", WithinSet.fromEnum(OptionEnum.class))
        .add("my_field", Types.TEXT);
    assertThat(optionArgWithCustomName.getType(), is(expectedType));

    ArgumentList myArgs = ArgumentList.fromArray(
        new FunctionArgument("foo", Types.TEXT),
        new FunctionArgument("bar", Types.TEXT),
        optionArgWithCustomName
    );

    Options opts = FunctionCallOptions.bindOptions("my_options", Options.class, realizationContext, myArgs,
        // we abuse funcWithOptions just a little to pass in many function args
        funcWithOptions("'foo', my_options: {required: 'foo'}, bar: 'bar'")
    ).get();

    assertThat(opts.required, is("foo"));
  }

  @Test
  public void testBindToTuple() {
    assertThat(
        // no options is an empty optional
        bindTuple("").isEmpty(),
        is(true)
    );

    // error if not constant
    assertThat(bindTuple("{foo: bar}").get(), Matchers.failedResult(Matchers.hasAncestorProblem(
        is(ExpressionProblems.get().constantRequired(expressionParser.parse("{foo: bar}")))
    )));
  }

  @Test
  public void errorOnBadValues() {
    // bad number value
    assertThat(bindOptions("{number: 'four', required: 'foo'}"), Matchers.failedResult(Matchers.hasAncestorProblem(
        is(NumberBinder.PROBLEMS.numberFormatException("four", Integer.class).affecting("four"))
    )));

    // bad enum value
    assertThat(
      bindOptions("{option_enum: 'never', required: 'foo'}"),
      Matchers.failedResult(
        Matchers.hasAncestorProblem(
            is(GeneralProblems.get().notAnOption(
                "never",
                OptionEnum.class,
                Arrays.asList(OptionEnum.class.getEnumConstants())
            )
        )
    )));
  }

  public void failsWhenArgListHasNoOptions() {
    ArgumentList noOptionsArgList = ArgumentList.fromArray(
        new FunctionArgument("foo", Struct.EMPTY_STRUCT)
    );
    RuntimeException ex = assertThrows(RuntimeException.class,
        () -> FunctionCallOptions.bindOptions(Options.class, realizationContext,
            noOptionsArgList, funcWithOptions("")));

    assertThat(ex.getMessage(), is("cannot bindOptions when argList has no options"));
  }

  @Test
  public void errorOnEextraParameters() {
    assertThat(bindOptions("{required: 'foo', text: 'foo', foo: 22, bar: 88}"), Matchers.failedResult(
        Matchers.hasAncestorProblem(is(GeneralProblems.get().notAnOption("foo", "options",
            Lists.newArrayList("anything", "floating", "my_field", "number", "option_enum", "required", "text")
        ).affecting("foo")))
    ));
  }

  private Optional<ResultOrProblems<Tuple>> bindTuple(String options) {
    return FunctionCallOptions.bindTuple(realizationContext, argumentList, funcWithOptions(options));
  }

  private ResultOrProblems<Options> bindOptions(String options) {
    return bindOptions(options, Options.class);
  }

  private <T> ResultOrProblems<T> bindOptions(String options, Class<T> optionsClass) {
    return FunctionCallOptions.bindOptions(optionsClass, realizationContext, argumentList,
        funcWithOptions(options));
  }

  private FunctionCall funcWithOptions(String options) {
    return expressionParser.parse(String.format("foo(%s)", options))
        .isA(FunctionCall.class).get();
  }

}
