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

import static nz.org.riskscape.dsl.ProblemCodes.*;
import static nz.org.riskscape.engine.Matchers.*;
import static nz.org.riskscape.engine.problem.ProblemMatchers.*;
import static nz.org.riskscape.engine.problem.ProblemMatchers.isProblem;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.junit.Before;
import org.junit.Test;

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

import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.bind.BoundParameters;
import nz.org.riskscape.engine.bind.InputFieldProperty;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.bind.ParameterSet;
import nz.org.riskscape.engine.bind.ParameterTemplate;
import nz.org.riskscape.engine.bind.TypedProperty;
import nz.org.riskscape.engine.bind.UserDefinedParameter;
import nz.org.riskscape.engine.model.Model;
import nz.org.riskscape.engine.model.ModelFramework;
import nz.org.riskscape.engine.model.ModelParameter;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.relation.ListRelation;
import nz.org.riskscape.engine.steps.FilterStep;
import nz.org.riskscape.engine.steps.RelationInputStep;
import nz.org.riskscape.engine.steps.SaveStep;
import nz.org.riskscape.engine.steps.SelectStep;
import nz.org.riskscape.engine.steps.SortStep;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.rl.ast.Expression;

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

  ParameterizedPipelineModelFramework framework = new ParameterizedPipelineModelFramework(engine);

  Map<String, List<?>> unbound;
  BoundParameters bound;
  ResultOrProblems<Model> builtModel;
  ResultOrProblems<RealizedPipeline> realizedPipeline;
  ParameterTemplate template;

  public ParameterizedPipelineModelFrameworkTest() {
    addPickledData("cars", ListRelation.ofValues("Subaru", "VW", "Toyota"));

    engine.getPipelineSteps().addAll(Lists.newArrayList(
        new RelationInputStep(engine),
        new FilterStep(engine),
        new SelectStep(engine),
        new SortStep(engine),
        new SaveStep(engine)
    ));
    template = new ParameterTemplate(Optional.of("foo"), Optional.of("More detailed foo"),
        Set.of(InputFieldProperty.HIDDEN, TypedProperty.EXPRESSION),
        Collections.emptyList());
    project.getParameterTemplates().add(UserDefinedParameter.wrap("template-foo", template));
  }

  @Before
  public void reset() {
    unbound = new HashMap<>();
    bound = null;
    builtModel = null;
    realizedPipeline = null;
  }

  @Test
  public void buildsAParameterSetFromAPipelineDeclarationAndUnboundParameters() {
    // throw the same parameter in twice to show it doesn't choke
    putParam("pipeline", "input($foo) -> select($bar + $bar)");
    putParam("param.foo", "'bar'");
    putParam("param.baz", "{x: true}");
    Model model = build(unbound);

    ParameterSet set = model.getBoundParameters().getBoundTo();
    Parameter fooParameter = set.get("foo");
    assertThat(fooParameter, isRequiredParameter(is("foo"), is(Expression.class)));
    assertThat(fooParameter.getDefaultValues(bindingContext), contains(ExpressionParser.parseString("'bar'")));

    Parameter barParameter = set.get("bar");
    assertThat(barParameter, isRequiredParameter(is("bar"), is(Expression.class)));
    // no default given via ini params
    assertThat(barParameter.getDefaultValues(bindingContext), empty());

    assertFalse(set.contains("baz"));

    assertThat(model.getBoundParameters().getUnbound(), aMapWithSize(1));
    assertTrue(model.getBoundParameters().getUnbound().containsKey("param.baz"));
  }

  @Test
  public void canBuildModelWithMissingParameters() {
    putParam("pipeline", "input(relation: $rel)");
    Model model = assertCanBuild(unbound);

    assertThat(model.getBoundParameters().getBoundTo().getDeclared(), contains(
        isRequiredParameter(is("rel"), is(Expression.class))
    ));
  }

  @Test
  public void canRealizeModelWithParameters() {
    putParam("pipeline", "input(relation: $rel)");
    putParam("rel", "'cars'");

    RealizedPipeline realized = buildAndRealize(unbound);
    assertNotNull(realized);
    assertFalse(realizedPipeline.hasProblems());

    assertThat(getPipelineDslWithParametersReplaced(), is("input(relation: 'cars')"));
  }

  @Test
  public void canBuildModelWithParameterTemplate() {
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", "'cars'");
    putParam("param.rel.template", "template-foo");
    Model model = assertCanBuild(unbound);

    // check that the parameter inherited the template's settings
    ModelParameter relParam = model.getModelParameter("rel").get();
    assertThat(relParam.getLabel(), is(template.getLabel().get()));
    assertThat(relParam.getDescription(Locale.getDefault()), is(template.getDescription().get()));
    assertThat(relParam.getProperties(), is(template.getProperties()));
  }

  @Test
  public void canInferParameterPropertiesFromDefaultValue() {
    putParam("pipeline", "input(value: { $foo as bar })");
    putParam("param.foo", "[ 1, 2, 3 ]");
    Model model = assertCanBuild(unbound);

    ModelParameter relParam = model.getModelParameter("foo").get();
    assertThat(relParam.getProperties(), containsInAnyOrder(
        TypedProperty.NUMERIC, TypedProperty.LIST, InputFieldProperty.MULTISELECT)
    );
  }

  @Test
  public void canStillBuildModelWithBadParameterTemplate() {
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", "'cars'");
    putParam("param.rel.template", "does-not-exist");
    putParam("param.rel.properties", "bad-property");

    // can build the model, but bad template config causes warnings
    build(unbound);
    assertTrue(builtModel.isPresent());
    assertTrue(builtModel.hasProblems());
    assertFalse(builtModel.hasErrors());
  }

  @Test
  public void canBuildModelAndExtendParameterTemplate() {
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", "'cars'");
    putParam("param.rel.template", "template-foo");
    putParam("param.rel.description", "Overriden description");
    Model model = assertCanBuild(unbound);

    // check that the parameter inherited the template's settings
    ModelParameter relParam = model.getModelParameter("rel").get();
    assertThat(relParam.getLabel(), is(template.getLabel().get()));
    assertNotEquals(relParam.getDescription(Locale.getDefault()), template.getDescription().get());
    assertThat(relParam.getDescription(Locale.getDefault()), is("Overriden description"));
    assertThat(relParam.getProperties(), is(template.getProperties()));
  }

  @Test
  public void canBuildModelWithBindingSpecifiedInParameterTemplate() {
    putParam("pipeline", "input(relation: $rel, limit: $num) -> select({*, $text as foo })");
    // note cars is missing single quotes here, which normally leads to invalid pipeline...
    putParam("param.rel", "cars");
    // .. but we can fix it by binding against the text class
    putParam("param.rel.properties", "text");
    putParam("param.num", "2");
    putParam("param.num.properties", "integer");
    putParam("param.text", "bar");
    putParam("param.text.properties", "text");

    RealizedPipeline realized = buildAndRealize(unbound);
    assertNotNull(realized);
    assertFalse(render(realizedPipeline.getProblems()), realizedPipeline.hasProblems());
  }

  @Test
  public void pipelineDSLisPrintedWithParametersReplaced() {
    putParam("pipeline", String.join(OsUtils.LINE_SEPARATOR,
        "# input relation comes from the $rel parameter",
        "input($rel, name: 'car')",
        "-> filter($filterCond) -> sort(car.name)",
        "# another comment",
        "  -> save(name: $rel)"));

    putParam("rel", "'cars'");
    putParam("filterCond", "true");

    assertCanBuild(unbound);

    // parameters get replaced whereever they occur in the pipeline dsl, but not in comments
    assertThat(getPipelineDslWithParametersReplaced(), is(String.join(OsUtils.LINE_SEPARATOR,
        "# input relation comes from the $rel parameter",
        "input('cars', name: 'car')",
        "-> filter(true) -> sort(car.name)",
        "# another comment",
        "  -> save(name: 'cars')")));
  }

  @Test
  public void pipelineDSLisPrintedWithParametersReplacedKeepingWhiteSpaceInParameterReplacements() {
    putParam("pipeline", String.join(OsUtils.LINE_SEPARATOR,
        "# input relation comes from the $rel parameter",
        "input($rel, name: 'car')",
        "-> filter($filterCond) -> sort(car.make)",
        "-> select($selections)",
        "# another comment",
        "  -> save(name: $rel)"));
    putParam("rel", "'cars'");
    putParam("filterCond", "true");
    putParam("selections", String.join(OsUtils.LINE_SEPARATOR,
        "{",
        "  make: car.make,",
        "  model: car.model",
        "}"));

    assertCanBuild(unbound);

    // parameters get replaced whereever they occur in the pipeline dsl, but not in comments
    assertThat(getPipelineDslWithParametersReplaced(), is(String.join(OsUtils.LINE_SEPARATOR,
        "# input relation comes from the $rel parameter",
        "input('cars', name: 'car')",
        "-> filter(true) -> sort(car.make)",
        "-> select({",
        "  make: car.make,",
        "  model: car.model",
        "})",
        "# another comment",
        "  -> save(name: 'cars')")));
  }

  @Test
  public void userParameterValuesOverrideModelParameters() {
    putParam("pipeline", "input(value: $val)");
    putParam("param.val", "12");  // model parameter
    putParam("val", "10");        // user parameter

    Model model = build(unbound);
    assertFalse(builtModel.hasProblems());
    assertThat(getPipelineDslWithParametersReplaced(), is("input(value: 10)"));
  }

  @Test
  public void spuriousParameterAreWarned() {
    putParam("pipeline", "input(value: $val)");
    putParam("param.val", "12");
    putParam("param.foo", "bar"); // this spurious parameter is in the model definition
    putParam("bar", "10");        // this spurious parameter would come from the users run params

    Model model = build(unbound);
    // there shouldn't be any errors
    assertFalse(builtModel.hasErrors());
    // should have problems though
    assertTrue(builtModel.hasProblems());

    assertThat(builtModel.getProblems(), contains(
        isProblem(ParamProblems.class, (r, problems) ->
            problems.ignoredWithHints(r.eq(Sets.newHashSet("param.foo", "bar")), r.eq(Sets.newHashSet("val"))))
    ));

    // sanity check that the built model is as expected
    assertNotNull(model);
    assertThat(getPipelineDslWithParametersReplaced(), is("input(value: 12)"));
  }

  @Test
  public void cannotRealizeModelWithMissingParameters() {
    putParam("pipeline", "input(relation: $rel)");

    RealizedPipeline realized = buildAndRealize(unbound);
    assertNull(realized);
    assertThat(realizedPipeline, failedResult(hasAncestorProblem(
      isProblem(GeneralProblems.class, (r, gp) ->
        gp.required(r.match(namedArg(Parameter.class, "rel")))
    ))));
  }

  @Test
  public void canSpecifyNoParameterValue() {
    putParam("pipeline", "input(relation: $rel)");
    // mostly just checking this doesn't explode here
    // it's the project.ini equivalent of: param.rel =
    unbound.put("param.rel", Collections.emptyList());
    // model should build OK, but won't realize
    RealizedPipeline realized = buildAndRealize(unbound);
    assertNull(realized);
    assertThat(realizedPipeline, failedResult(hasAncestorProblem(
      isProblem(GeneralProblems.class, (r, gp) ->
        gp.required(r.match(namedArg(Parameter.class, "rel")))
    ))));
  }

  @Test
  public void cannotRealizeModelWithBadPipeline() {
    putParam("pipeline", "input(relation: $rel) -> bogus()");
    putParam("rel", "'cars'");

    Model model = build(unbound);
    assertNotNull(model);
    ResultOrProblems<RealizedPipeline> realized = model.realize(executionContext);

    assertThat(
      realized,
      result(hasProperty(
        "failures",
        contains(hasAncestorProblem(
          is(GeneralProblems.get().noSuchObjectExists("bogus", Step.class))
        ))
      ))
    );
  }

  @Test
  public void cannotRealizeModelWithBadParameterValue() {
    putParam("pipeline", "input(relation: $rel) -> filter($filter)");
    putParam("rel", "'cars'");
    putParam("filter", "'let it pass'");

    RealizedPipeline realized = buildAndRealize(unbound);
    assertTrue(realized.hasFailures());
    assertTrue(realized.getStep("filter").get().isFailed());
  }

  @Test
  public void cannotParameterizeStep() {
    putParam("pipeline", "$stepId($stepInput)");
    putParam("stepId", "'input'");
    putParam("stepInput", "'cars'");

    Model model = build(unbound);
    assertNull(model);
    assertThat(builtModel, failedResult(hasAncestorProblem(isError(UNEXPECTED_TOKEN))));
  }

  @Test
  public void cannotParameterizeStepName() {
    putParam("pipeline", "input(relation: 'cars') as $stepName");
    putParam("stepName", "'my-input'");

    Model model = build(unbound);
    assertNull(model);
    assertThat(builtModel, failedResult(hasAncestorProblem(isError(UNEXPECTED_TOKEN))));
  }

  @Test
  public void cannotParameterizeParameterName() {
    putParam("pipeline", "input($paramName: 'cars')");
    putParam("paramName", "'relation'");

    Model model = build(unbound);
    assertNull(model);
    assertThat(builtModel, failedResult(hasAncestorProblem(isError(UNEXPECTED_TOKEN))));
  }

  @Test
  public void canOnlySpecifyParametersOnce() {
    putParam("pipeline", "input(relation: $rel)");
    unbound.put("param.rel", Lists.newArrayList("'cars'", "'trucks'"));

    Model model = build(unbound);
    assertTrue(builtModel.isPresent());
    assertTrue(builtModel.hasProblems());
    assertFalse(builtModel.hasErrors());
    assertThat(builtModel.getProblems(), contains(
        is(ParamProblems.get().wrongNumberGiven("rel", "1", 2).withSeverity(Severity.WARNING))
    ));
    // NB: model parameters are arity 1..1, so we only get back the first bound value
    assertThat(model.getBoundParameters().getValue("rel"), is(expressionParser.parse("'cars'")));
  }

  @Test
  public void cannotReuseAFrameworkParameterNameInModel() {
    putParam("pipeline", "input(relation: $location)");
    putParam("param.location", "'cars'");

    build(unbound);
    assertThat(builtModel, failedResult(hasAncestorProblem(
        is(GeneralProblems.get().nameAlreadyUsedBy("location", ModelFramework.class, Parameter.class))
    )));
  }

  @Test
  public void willTryTemplateOfSameNameIfOneExists() {
    // Define a model with a parameter
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", "'cars'");

    // Add a parameter template with the same name to the project
    ParameterTemplate relTemplate = template.withLabel("Label");
    project.getParameterTemplates().add(UserDefinedParameter.wrap("rel", relTemplate));

    build(unbound);

    ModelParameter parameter = builtModel.get().getModelParameters().get(0);

    // Confirm that the built model's parameter links to the template
    assertThat(parameter.getTemplate(), is(relTemplate));

    // Confirm that the label got through
    assertThat(parameter.getLabel(), is("Label"));

    // Make sure we haven't messed up the value we set
    assertThat(parameter.getParameter().getDefaultValues(bindingContext),
            contains(ExpressionParser.parseString("'cars'")));
  }

  @Test
  public void specifiedTemplateOverridesAutomatic() {
    // Define a model with a parameter, and a template to use with that parameter
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", "'cars'");
    putParam("param.rel.template",  "chosen");

    // Add the template that the user wants to use
    ParameterTemplate chosenTemplate = template.withLabel("Chosen");
    project.getParameterTemplates().add(UserDefinedParameter.wrap("chosen", chosenTemplate));

    // Add a template with the same name as the parameter - we should NOT use this one
    project.getParameterTemplates().add(UserDefinedParameter.wrap("rel", template.withLabel("Label")));


    build(unbound);

    // Confirm the built model's parameter links to the correct template
    assertThat(builtModel.get().getModelParameters().get(0).getTemplate(), is(chosenTemplate));
  }

  @Test
  public void modelParametersAreOrdered() {
    // for now, parameter order is just the order that the parameters appear in the pipeline.
    // This isn't the most sensible approach - see platform#636 and platform#406 for improvements
    putParam("pipeline", "input(value: { $one as a, $two as b, $three as c, $four as d})");
    putParam("param.three", "3");
    putParam("param.four", "4");
    putParam("param.two", "2");
    putParam("param.one", "1");

    build(unbound);

    // Confirm the built model's parameters are ordered
    assertThat(builtModel.get().getModelParameters(), contains(
               is(builtModel.get().getModelParameter("one").get()),
               is(builtModel.get().getModelParameter("two").get()),
               is(builtModel.get().getModelParameter("three").get()),
               is(builtModel.get().getModelParameter("four").get())
    ));
  }

  @Test
  public void usesTemplateDefaults() {
    // Define a model with a parameter
    putParam("pipeline", "input(relation: $rel)");

    String defaultValue = "cars"; // Note that text string doesn't need quoting

    // Add in a template with a default value - we don't need to explicitly specify that the parameter should use it
    // because it has the same name
    project.getParameterTemplates().add(
            UserDefinedParameter.wrap("rel", template.withDefaultValue(defaultValue))
    );

    buildAndRealize(unbound);

    // The default value should find its way into the pipeline
    assertThat(getPipelineDslWithParametersReplaced(), containsString(defaultValue));
  }

  @Test
  public void specifiedValueOverridesTemplate() {

    String setValue = "'cars'";
    String templateDefaultValue = "Trucks";

    // Define a model with a parameter, and provide a value for that parameter
    putParam("pipeline", "input(relation: $rel)");
    putParam("param.rel", setValue);

    // Add in a template with a default value
    project.getParameterTemplates().add(
            UserDefinedParameter.wrap("rel", template.withDefaultValue("Trucks"))
    );

    buildAndRealize(unbound);

    // Check that the set value and not the default value was used in the pipeline
    assertThat(getPipelineDslWithParametersReplaced(),
            both(containsString(setValue)).and(not(containsString(templateDefaultValue))));
  }

  private void putParam(String name, String... values) {
    unbound.put(name, Lists.newArrayList(values));
  }

  private Model build(Map<String, List<?>> unboundParams) {
    bound = framework.getBuildParameterSet().bind(bindingContext, unboundParams);
    builtModel = framework.build(project, bound);
    return builtModel.orElse(null);
  }

  private Model assertCanBuild(Map<String, List<?>> unboundParams) {
    build(unboundParams);
    if (!builtModel.isPresent()) {
      fail("model failed to build: " + render(builtModel.getProblems()));
      return null;
    }
    assertFalse(render(builtModel.getProblems()), builtModel.hasProblems());
    return builtModel.get();
  }

  private RealizedPipeline buildAndRealize(Map<String, List<?>> unboundParams) {
    Model model = assertCanBuild(unboundParams);
    return realize(model);
  }

  private RealizedPipeline realize(Model model) {
    realizedPipeline = model.realize(executionContext);
    return realizedPipeline.orElse(null);
  }

  private String getPipelineDslWithParametersReplaced() {
    return builtModel
        // should be checking for model warnings/errors separately in the test case
        .getWithProblemsIgnored().realize(executionContext)
        .map(RealizedPipeline::getAst)
        .map(pd -> pd.getBoundary().get().getLeft().source.readFully())
        .orElse(null);
  }

}
