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

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

import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.net.URI;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import org.junit.Ignore;
import org.junit.Test;

import com.google.common.collect.Lists;

import nz.org.riskscape.engine.Matchers;
import nz.org.riskscape.engine.cli.BaseCliIntegrationTest.ExecResult;
import nz.org.riskscape.engine.cli.ExitException;
import nz.org.riskscape.engine.cli.ProcessRunner;
import nz.org.riskscape.engine.cli.tests.BaseModelRunCommandTest;
import nz.org.riskscape.engine.resource.FileResource;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.resource.ResourceProblems;
import nz.org.riskscape.engine.test.EngineTestPlugins;
import nz.org.riskscape.engine.test.EngineTestSettings;


@EngineTestPlugins({"defaults", "cpython"})
@EngineTestSettings({"cpython.python3-bin=python3", "cpython.lib-dir=lib"})
public class CPythonIntegrationTest extends BaseModelRunCommandTest {

  @Test
  public void canRunATwoFunctionPipelineWithSomeNonTrivialTypes() throws Exception {

    runCommand.modelId = "road-losses";
    runCommand.run();

    // check model outputs the expected exposed buildings (in order because they're sorted)
    List<List<String>> rows = openCsv(
      "road_losses.csv",
      "road.name", "road.surface_type", "road.length", "road_damage", "road_replacement_cost"
    );

    assertThat(rows,
      containsInAnyOrder(
        contains(equalTo("smith street"),equalTo("gravel"),equalTo("100"),startsWith("0.29"),equalTo("2900")),
        contains(equalTo("oak avenue"),equalTo("asphalt"),equalTo("200"),startsWith("0.03"),equalTo("1800")),
        contains(equalTo("mana crescent"),equalTo("unsealed"),equalTo("150"),startsWith("0.58"),equalTo("8700"))
      )
    );

    //  Check console output came out correctly and completely - nb this will sometimes fail if we're not closing
    // and cleaning up our cpython functions correctly
    Resource roadReplacementCostScript =
        new FileResource(stdhome().resolve("road_replacement_cost.py").toAbsolutePath());

    Resource roadDamageScript =
        new FileResource(stdhome().resolve("road_damage.py").toAbsolutePath());

    assertThat(
      collectedSinkProblems,
      containsInAnyOrder(
        CPythonProblems.get().functionOutput(
          roadReplacementCostScript,
          "Computing damage for replacement of {'surface_type': 'gravel', 'length': 100}"
        ),
        CPythonProblems.get().functionOutput(
          roadReplacementCostScript,
          "Computing damage for replacement of {'surface_type': 'asphalt', 'length': 200}"
        ),
        CPythonProblems.get().functionOutput(
          roadReplacementCostScript,
          "Computing damage for replacement of {'surface_type': 'unsealed', 'length': 150}"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Surface type was gravel"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Surface type was asphalt"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Surface type was unsealed"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Damage was 0.29"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Damage was 0.03"
        ),
        CPythonProblems.get().functionOutput(
          roadDamageScript,
          "Damage was 0.58"
        )
      )
    );

    // check all the cpython processes have been killed - these stay around (and you can test this) if you don't close
    // the pool
    ProcessRunner runner = new ProcessRunner();
    ExecResult result = runner.exec(Paths.get("."), Arrays.asList("ps", "aux"), Optional.empty());

    assertThat(result.retCode, equalTo(0));
    assertThat(
      result.stdout.stream()
        .filter(line -> line.contains(CPythonSettings.DEFAULT_ADAPTOR_PATH))
        .collect(Collectors.toList()),
      emptyIterable()
    );

  }

  @Test
  public void canRunAStupidlySimpleFunctionAgainstALotOfValues() throws Exception {
    runCommand.modelId = "count-numbers";
    runCommand.run();

    // check model outputs the expected exposed buildings (in order because they're sorted)
    List<List<String>> rows = openCsv("group.csv",
      "count_int","sum_int"
    );
    assertThat(rows,
        contains(
        contains(equalTo("5000"),equalTo("12497500"))
      )
    );
  }

  @Test
  public void performanceStatsIncludedInModelRunStats() throws Exception {
    runCommand.modelId = "count-numbers";
    runCommand.run();

    // check model outputs the expected exposed buildings (in order because they're sorted)
    List<List<String>> rows = openCsv("model-run-stats.csv",
      "name", "runtime-ms", "runtime-average-ms", "tuples-in", "tuples-in-per-sec",
      "tuples-out", "tuples-out-per-sec", "context-switches"
    );
    assertThat(rows, hasItem(
        contains(
            is("Cpython_Function_cpython_int"),
            matchesPattern("\\d+"),
            matchesPattern("\\d+.\\d+"),
            is("5000"),
            is(""), // blank, we don't capture per-sec
            is("5000"),
            is(""), // blank, we don't capture per-sec
            is("")  // no context switch metric for functions
        )
      )
    );
  }

  @Test
  public void canPassUnexpectedNullsToCPythonFunction() throws Exception {
    runCommand.modelId = "pass_nulls_to_function";
    runCommand.run();

    List<List<String>> rows = openCsv("output.csv",
      "ID", "result.foo", "result.bar", "result.baz", "result.all_nulls"
    );
    // spot-check a couple of rows of output
    assertThat(rows, allOf(
        // this row was mostly nulls originally
        hasItem(contains(equalTo("100"), equalTo(""), equalTo(""), equalTo(""), equalTo(""))),
        // this row was mostly non-null originally
        hasItem(contains(equalTo("10007"), equalTo("3"), equalTo("1.0"), equalTo("one"), equalTo("")))
    ));
  }

  @Test
  public void canUseFunctionLoadedOverHttps() throws Exception {
    runCommand.modelId = "adder";
    runCommand.run();

    List<List<String>> rows = openCsv("results.csv",     "result");
    // spot-check a couple of rows of output
    assertThat(rows, contains(
        contains("30.0")
    ));
  }

  @Test
  public void cannotUseFunctionLoadedOverHttp() throws Exception {
    runCommand.modelId = "adder";
    runCommand.parameters = Lists.newArrayList("functionExpr=add_http(a,b)");
    ExitException ex = assertThrows(ExitException.class, () -> runCommand.run());
    assertThat(ex.getProblem(), Matchers.hasAncestorProblem(
        is(ResourceProblems.get().insecureNotAllowed(URI.create(
            "http://" + HTTPS_TEST_URI + "/test/cpython/add.py")))
    ));
  }

  @Test
  public void cannotUseMissingFunctionLoadedOverHttps() throws Exception {
    runCommand.modelId = "adder";
    runCommand.parameters = Lists.newArrayList("functionExpr=add_missing(a,b)");
    ExitException ex = assertThrows(ExitException.class, () -> runCommand.run());
    assertThat(ex.getProblem(), Matchers.hasAncestorProblem(
        is(ResourceProblems.get().notFound(URI.create(
            "https://" + HTTPS_TEST_URI + "/test/cpython/add_missing.py")))
    ));
  }

  @Test
  public void canImportFromProjectDirectory() throws Exception {
    runCommand.modelId = "two_functions";
    runCommand.run();

    List<List<String>> rows = openCsv("select.csv",
        "result"
    );
    assertThat(rows.get(0).get(0), equalTo("100"));
  }

  @Test
  public void canImportFromProjectDirectoryIfProjectDirectorySpecified() throws Exception {
    // Making sure that our sys.path thing still works if the user specifies a directory rather than a project.ini file

    // Equivalent to `--project .`
    project.setRelativeTo(project.getRelativeTo().resolve("."));

    runCommand.modelId = "two_functions";
    runCommand.run();

    List<List<String>> rows = openCsv("select.csv",
        "result"
    );
    assertThat(rows.get(0).get(0), equalTo("100"));
  }

  // XXX: these tests seem to leave cpython adaptor  processes running (which upsets
  // canRunATwoFunctionPipelineWithSomeNonTrivialTypes). So we ignore them for now.
  @Test @Ignore
  public void brokenFunctionHasErrorWithCorrectSource() throws Exception {
    runCommand.modelId = "adder";
    runCommand.parameters = Lists.newArrayList("functionExpr=broken_add(a,b)");
    ExitException ex = assertThrows(ExitException.class, () -> runCommand.run());
    String problemText = render(ex.getProblem());
    assertThat(problemText, allOf(
        containsString("Problems found with 'broken_add' function (from source file:"),
        containsString("riskscape/plugin/cpython/src/test/homes/CPythonIntegrationTest/broken-add.py")
    ));

  }

  // XXX: these tests seem to leave cpython adaptor  processes running (which upsets
  // canRunATwoFunctionPipelineWithSomeNonTrivialTypes). So we ignore them for now.
  @Test @Ignore
  public void brokenInlineFunctionHasErrorWithCorrectSource() throws Exception {
    runCommand.modelId = "adder";
    runCommand.parameters = Lists.newArrayList("functionExpr=broken_add_inline(a,b)");
    ExitException ex = assertThrows(ExitException.class, () -> runCommand.run());
    String problemText = render(ex.getProblem());
    assertThat(problemText, allOf(
        containsString("Problems found with 'broken_add_inline' function (from source file:"),
        containsString("riskscape/plugin/cpython/src/test/homes/CPythonIntegrationTest/project.ini")
    ));
  }

}
