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

import static nz.org.riskscape.engine.Assert.*;
import static nz.org.riskscape.engine.Matchers.*;

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

import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;

import org.junit.Test;

import lombok.Getter;

import nz.org.riskscape.config.ConfigProblems;
import nz.org.riskscape.engine.bind.BoundParameters;
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.data.Bookmark;
import nz.org.riskscape.engine.geo.GeometryValidation;
import nz.org.riskscape.engine.model.IdentifiedModel;
import nz.org.riskscape.engine.model.Model;
import nz.org.riskscape.engine.model.ModelFramework;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.problem.ProblemMatchers;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.typeset.CanonicalType;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;

public class DefaultProjectBuilderTest extends ProjectTest {

  static class TestFramework implements ModelFramework {

    @Getter
    private final String id = "test";

    @Getter
    private final ParameterSet buildParameterSet = ParameterSet.from(Parameter.required("foo", String.class));

    @Override
    public ResultOrProblems<Model> build(Project project, BoundParameters values) {
      Model mockModel = mock(Model.class);
      when(mockModel.getBoundParameters()).thenReturn(values);

      List<Problem> problems = new ArrayList<>();
      Set<String> extra = values.getExtraneous().keySet();
      if (! extra.isEmpty()) {
        problems.add(ParamProblems.get().ignored(extra.toString()));
      }
      return ResultOrProblems.of(mockModel).withMoreProblems(problems);
    }

  }

  ProjectBuilder builder = new DefaultProjectBuilder();
  List<Plugin> plugins;

  @Override
  public List<Plugin> getPlugins() {
    plugins = new ArrayList<>();

    return plugins;
  }

  @Override
  protected void populateEngineWithDefaults(DefaultEngine newEngine) {
    super.populateEngineWithDefaults(newEngine);

    newEngine.getModelFrameworks().add(new TestFramework());
  }

  @Test
  public void pluginInitializationCausesEngineToNotInitializeButWithaNiceErrorRm269() {
    Plugin goodOne = mock(Plugin.class);
    Plugin badOne = mock(Plugin.class);
    Plugin greatOne = mock(Plugin.class);

    plugins.add(goodOne);
    plugins.add(badOne);
    plugins.add(greatOne);

    when(goodOne.initializeProject(same(project), same(engine))).thenReturn(Collections.emptyList());
    when(badOne.initializeProject(same(project), same(engine)))
        .thenThrow(new OutOfMemoryError("no more mems!"));
    when(greatOne.initializeProject(same(project), same(engine))).thenReturn(Collections.emptyList());

    ResultOrProblems<Project> init = builder.init(project);
    assertFalse(init.isPresent());
    assertEquals(1, init.getProblems().size());
    Problem problem = init.getProblems().get(0);
    assertEquals("no more mems!", problem.getException().getMessage());
    assertTrue(problem.getMessage().startsWith("Failed to initialize plugin "));

  }

  @Test
  public void objectAlreadyExistsExceptionsFromPluginsAreNotCaught() {
    // test that these exceptions are not caught and wrapped in problems when thrown from plugins.
    Plugin throwsObjectAlreadyExists = mock(Plugin.class);
    plugins.add(throwsObjectAlreadyExists);

    ObjectAlreadyExistsException objectAlreadyExists = mock(ObjectAlreadyExistsException.class);
    when(throwsObjectAlreadyExists.initializeProject(same(project), same(engine)))
        .thenThrow(objectAlreadyExists);

    ObjectAlreadyExistsException caught = assertThrows(ObjectAlreadyExistsException.class,
        () -> builder.init(project));
    assertSame(objectAlreadyExists, caught);
  }


  @Test
  public void emptyConfigBuildsEmptyProject() throws Exception {

    ResultOrProblems<Project> eng = builder.init(project);
    assertThat(eng.hasProblems(), is(false));
    assertThat(project.getTypeSet().isEmpty(), is(true));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), is(empty()));
  }

  @Test
  public void wrappingQuotesAreRemovedFromIdentifiers() throws Exception {
    config("[project]",
        "name = project with a type",
        "[type 'my-integer']",
        "type = integer",
        "[type     \"my-float\"     ]",
        "type = floating");

    ResultOrProblems<Project> proj = builder.init(project);
    assertTrue(project.getTypeSet().containsKey("my-integer"));
    assertTrue(project.getTypeSet().containsKey("my-float"));

    assertFalse(project.getTypeSet().containsKey("'my-integer'"));
    assertFalse(project.getTypeSet().containsKey("\"my-float\""));
  }

  @Test
  public void missingIdentifierGivesMeaningfulError() throws Exception {
    config("[project]",
        "name = project with a type",
        "[type]",
        "type = integer");

    ResultOrProblems<Project> proj = builder.init(project);
    assertThat(proj.getProblems(), contains(
        Problems.foundWith("[type]", GeneralProblems.get().required("id"))
    ));

    config("[project]",
        "name = project with a type",
        "[type ' ']",
        "type = integer");

    builder = new DefaultProjectBuilder();
    proj = builder.init(project);
    assertThat(proj.getProblems(), contains(
        Problems.foundWith("[type ' ']", GeneralProblems.get().required("id"))
    ));

    config("[project]",
        "name = project with a type",
        "[type \" \"]",
        "type = integer");

    builder = new DefaultProjectBuilder();
    proj = builder.init(project);
    assertThat(proj.getProblems(), contains(
        Problems.foundWith("[type \" \"]", GeneralProblems.get().required("id"))
    ));
  }

  @Test
  public void projectCanContainAType_WhenAnotherTypeIsBroken() throws Exception {
    config("[project]",
        "name = project with a type",
        "",
        "[type broken-type]",
        "type = unknown",
        "",
        "[type my-type]",
        "type = integer");

    builder.init(project);

    assertThrows(FailedObjectException.class, () -> project.getTypeSet().get("broken-type"));

    CanonicalType type = project.getTypeSet().get("my-type");
    assertThat(type.getUnderlyingType(), is(Types.INTEGER));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), is(empty()));
  }

  @Test
  public void projectCanContainABookmark() throws Exception {
    config(
        "[project]",
        "name = project with a bookmark",
        "[bookmark assets]",
        "location = data/assets.csv",
        "format = csv");

    ResultOrProblems<Project> projectOr = builder.init(project);

    assertThat(projectOr.getProblems(), hasSize(0));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), hasSize(1));
    Bookmark b = project.getBookmarks().get("assets");
    assertThat(b.getId(), is("assets"));
    assertThat(b.getLocation(), is(URI.create("test:/data/assets.csv")));
  }

  @Test
  public void whiteSpaceIsTrimmedOfValues() throws Exception {
    // same as #projectCanContainABookmark except extra whitespace is added to the location value
    config(
        "[project]",
        "name = project with a bookmark",
        "[bookmark assets]",
        "location =          data/assets.csv     ",  // trailing spaces on location
        "format = csv");

    ResultOrProblems<Project> projectOr = builder.init(project);

    assertThat(projectOr.getProblems(), hasSize(0));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), hasSize(1));
    Bookmark b = project.getBookmarks().get("assets");
    assertThat(b.getId(), is("assets"));
    assertThat(b.getLocation(), is(URI.create("test:/data/assets.csv")));
  }

  @Test
  public void macronsAreRetained() throws Exception {
    // same as #projectCanContainABookmark except we add a description with a macron
    config(
        "[project]",
        "name = project with a bookmark",
        "[bookmark assets]",
        "description = Wānaka",
        "location = data/assets.csv",
        "format = csv");

    ResultOrProblems<Project> projectOr = builder.init(project);

    assertThat(projectOr.getProblems(), hasSize(0));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), hasSize(1));
    Bookmark b = project.getBookmarks().get("assets");
    assertThat(b.getId(), is("assets"));
    assertThat(b.getDescription(), is("Wānaka"));
    assertThat(b.getLocation(), is(URI.create("test:/data/assets.csv")));
  }

  @Test
  public void multiLineValuesRetainWhiteSpace() throws Exception {
    config(
        """
        [project]
        name = project with a bookmark
        [bookmark assets]
        description = '''
          a multiline value retains trailing whitespace
        '''
        location = data/assets.csv
        format = csv
        """
    );

    ResultOrProblems<Project> projectOr = builder.init(project);

    assertThat(projectOr.getProblems(), hasSize(0));
    assertThat(project.getFunctionSet().getAll(), is(empty()));
    assertThat(project.getBookmarks().getAll(), hasSize(1));
    Bookmark b = project.getBookmarks().get("assets");
    assertThat(b.getId(), is("assets"));
    assertThat(b.getDescription(), is("  a multiline value retains trailing whitespace\n"));
    assertThat(b.getLocation(), is(URI.create("test:/data/assets.csv")));
  }

  @Test
  public void projectCanContainAModel() throws Exception {
    config(
        "[project]",
        "name = project with a bookmark",
        "[model default-inner]",
        "framework = crazy days",
        "pipeline = input()");

    ResultOrProblems<Project> eng = builder.init(project);
    assertThat(eng.hasProblems(), is(false));
    assertThat(project.getFunctionSet().getAll(), is(empty()));

    ResultOrProblems<IdentifiedModel> model = project.getIdentifiedModels().getResult("default-inner").orElse(null);
    assertNotNull(model); // exists, but...
    assertFalse(model.isPresent()); // it has failures
  }


  @Test
  public void missingProjectSectionIsOK() throws Exception {
    config("[type simples]",
        "type = nullable(text)");

    assertThat(
        builder.init(project),
        result(isA(Project.class))
    );
  }

  @Test
  public void spuriousSectionsGetAWarning() throws Exception {
    config("[project]",
        "description = project with spurious sections",
        "[foo]",
        "[bar]");

    ResultOrProblems<Project> eng = builder.init(project);
    List<String> allowedKeys = Arrays.asList("project", "bookmark", "type", "model", "function", "parameter");
    assertThat(eng.getProblems(), contains(
        ProblemMatchers.isProblem(ConfigProblems.class, (r, p)
            -> p.spuriousSectionInProject(r.eq("[foo]"), r.eq(config.getSection("foo").get().getLocation()),
                r.eq(allowedKeys))),
        ProblemMatchers.isProblem(ConfigProblems.class, (r, p)
            -> p.spuriousSectionInProject(r.eq("[bar]"), r.eq(config.getSection("bar").get().getLocation()),
                r.eq(allowedKeys)))
    ));
  }

  @Test
  public void geometryValidationDefaultsToError() throws Exception {
    config("[project]",
        "description = project with default geometry validation");

    builder.init(project).get();
    assertThat(project.getGeometryValidation(), is(GeometryValidation.ERROR));
    assertThat(project.getSridSet().getValidationPostReproject(), is(GeometryValidation.ERROR));
  }

  @Test
  public void geometryValidationCanBeSet() throws Exception {
    config("[project]",
        "validate-geometry = warn");

    builder.init(project).get();
    assertThat(project.getGeometryValidation(), is(GeometryValidation.WARN));
    assertThat(project.getSridSet().getValidationPostReproject(), is(GeometryValidation.WARN));
  }

  @Test
  public void geometryValidationMustBeSetToValidValue() throws Exception {
    config("[project]",
        "validate-geometry = fixit");

    assertThat(builder.init(project).getAsSingleProblem(), hasAncestorProblem(
        is(GeneralProblems.notAnOption("fixit", GeometryValidation.class))
    ));
  }

  @Test
  public void outputBaseDefaultsToRelativeToProjectFile() {
    config("[project]");
    builder.init(project).get();
    // the project file is test:/project.ini
    assertThat(project.getOutputBaseLocation(), is(URI.create("test:/output/")));
  }

  @Test
  public void outputBaseDirectoryIsResolvedRelativeToProjectFile() {
    config("[project]",
        "output-base-location = my-output");  // no trailing slash, none is added
    builder.init(project).get();
    // the project file is test:/project.ini
    assertThat(project.getOutputBaseLocation(), is(URI.create("test:/my-output")));
  }

  @Test
  public void outputBaseDirectoryWithTrailingSlashIsResolvedRelativeToProjectFile() {
    config("[project]",
        "output-base-location = my-output/");
    builder.init(project).get();
    // the project file is test:/project.ini
    assertThat(project.getOutputBaseLocation(), is(URI.create("test:/my-output/")));
  }

  @Test
  public void outputBaseFileIsResolvedRelativeToProjectFile() {
    config("[project]",
        "output-base-location = path/to/output.gpkg");
    builder.init(project).get();
    // the project file is test:/project.ini
    // note no trailing slash, its a file (not directory)
    assertThat(project.getOutputBaseLocation(), is(URI.create("test:/path/to/output.gpkg")));
  }

  @Test
  public void outputBaseCanBeAbsolute() {
    config("[project]",
        "output-base-location = test2://my/location");
    builder.init(project).get();
    assertThat(project.getOutputBaseLocation(), is(URI.create("test2://my/location")));
  }

  @Test
  public void extraModelParametersAreWarned() {
    config("[project]",
        "[model has-unknown-params]",
        "framework = test",
        "description = test model with unknown params",
        "foo = 10", // this param is legit
        "bar = 30"  // this is not unknown
    );
    builder.init(project).get();

    List<Problem> problems = new ArrayList<>();
    IdentifiedModel model = project.getIdentifiedModels().get("has-unknown-params", p -> problems.add(p));
    // we are really checking that framework params do not become extraneous model parameters.
    assertThat(problems, contains(hasAncestorProblem(is(ParamProblems.get().ignored("[bar]")))));
  }

}
