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

import org.junit.Test;

import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.function.FunctionCallException;
import nz.org.riskscape.engine.function.IdentifiedFunction;
import nz.org.riskscape.engine.types.RSList;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;

/**
 * Checks we get sensible errors when we call badly written Python code.
 */
public class CPythonErrorsTest extends CPythonBaseTest {

  @Test
  public void getSensibleErrorWhenBadPythonSyntax() throws Exception {
    // we'll get a python syntax error trying to load this function
    String script = ""
        + "def function(a):\n"
        + "  return 'uh-oh\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    // we should point out:
    // - the user's function that had the problem
    // - the file/line number
    // - the problematic line of code
    // - the Python exception details
    assertThat(render(ex), allOf(
        containsString("Failed to load your CPython script for 'foo' function"),
        containsString("/test.py\", line 2"),
        containsString("  return 'uh-oh"),
        containsString("SyntaxError: unterminated string literal (detected at line 2)"),
        // ideally, the error should not refer to the built-in riskscape python code, as that's confusing
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH))
    ));
  }

  @Test
  public void getSensibleErrorWhenMixingTabsAndSpaces() throws Exception {
    // we'll get a python indentation error trying to load this function
    String script = ""
        + "def function(a):\n"
        + "\ta += 1\n"
        + "    return str(a)\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("/test.py\", line 3"),
        containsString("IndentationError:"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH))
    ));
  }

  @Test
  public void getSensibleErrorWithBadModuleImport() throws Exception {
    String script = ""
        + "import bad.juju\n"
        + "def function(a):\n"
        + "    return str(a)\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("/test.py\", line 1"),
        containsString("import bad.juju"),
        containsString("ModuleNotFoundError: No module named 'bad'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH))
    ));
  }

  @Test
  public void getSensibleErrorWithUndeclaredVariable() throws Exception {
    // the commonest of common python errors
    String script = ""
        + "def function(a):\n"
        + "    return str(not_defined)\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    System.out.print(render(ex));

    assertThat(render(ex), allOf(
        containsString("/test.py\", line 2"),
        containsString("return str(not_defined)"),
        containsString("NameError: name 'not_defined' is not defined"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH))
    ));
  }

  @Test
  public void getSensibleMesageFromPythonRuntimeErrors() throws Exception {
    // this will load OK, but throw an error when we run it
    String script = ""
        + "def function(a):\n"
        + "    derp = a / 0\n"
        + "    return derp\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("/test.py\", line 2"),
        containsString("ZeroDivisionError: division by zero"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH))
    ));
  }

  @Test
  public void getSensibleMesageWhenReturningWrongThing() throws Exception {
    // function tries to return text when it said it'd return an integer
    String script = ""
        + "def function(a):\n"
        + "    return 'bang!'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.INTEGER);
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    // we should try to explain the problem to the user rather than throwing a raw python error
    assertThat(render(ex), allOf(
        containsString("Error: Could not return value 'bang!' as a 'Integer'"),
        containsString("You may have specified the wrong return-type in your function's INI file definition"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void errorHighlightsStructMemberWhenReturningBadThing() throws Exception {
    String script = ""
        + "def function(a):\n"
        + "    return { 'foo': 'okay', 'bar': 'bang!' }\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Struct.of("foo", Types.TEXT, "bar", Types.FLOATING));
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    // just saying a float has a problem isn't particularly useful when returning a struct.
    // What'd be helpful is knowing *which* float in the struct has the problem
    assertThat(render(ex), allOf(
        containsString("Error: Problem found returning attribute 'bar' in struct"),
        containsString("Could not return value 'bang!' as a 'Floating'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorWhenReturningNothing() throws Exception {
    String script = ""
        + "def function(a):\n"
        + "    return None\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.FLOATING);
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Could not return value 'None' as a 'Floating'"),
        // give the user a little nudge as they may not know about nullable
        containsString("possible solution may be to define this type as 'nullable'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfUserLiesAboutReturningStructs() throws Exception {
    String script = ""
        + "def function(a):\n"
        + "    return 1.0\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Struct.of("bar", Types.FLOATING));
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    // if user wants to return a struct, but is clearly returning something else,
    // then it's probably helpful to remind them *how* they do that in python
    assertThat(render(ex), allOf(
        containsString("Could not return value '1.0' as a 'Struct'"),
        containsString("Make sure your python code returns Structs in the format: "
            + "{ 'name1': value1, 'name2': value2 }"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfKeyMissingFromStruct() throws Exception {
    String script = ""
        + "def function(a):\n"
        + "    return { 'foo': 'what about bar...?' } \n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Struct.of("foo", Types.TEXT, "bar", Types.FLOATING));
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    // python's KeyError really isn't informative here - give the user a better message
    assertThat(render(ex), allOf(
        containsString("Problem found returning Struct - attribute 'bar' was missing"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfUserLiesAboutReturningBoolean() throws Exception {
    String script = ""
        + "def function(a):\n"
        + "    return 'foo'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.BOOLEAN);
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Could not return value 'foo' as a 'Bool'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfUserLiesAboutReturningLists() throws Exception {
    // note that serializing lists can 'just work' in some corner cases, e.g.
    // `return {'foo': 'bar'}` as a List[Text] works, as does `return 'foo'`.
    // But I'm not sure we actually care, as users are unlikely to return lists.
    // Likewise the error message gets a bit more convoluted if we try to return
    // a struct in python but say it's a list in the INI file. Again, I don't think
    // we really care...
    // Mostly we're checking the serialization code doesn't blow up here
    String script = ""
        + "def function(a):\n"
        + "    return 1.0\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        RSList.create(Types.FLOATING));
    FunctionCallException ex = assertThrows(FunctionCallException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Could not return value '1.0' as a 'List'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfUserMisnamesTheirFunction() throws Exception {
    String script = ""
        + "def foo(a):\n"
        + "    return 'bar'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Error: No 'def function' present"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorIfWeAreGivenANonFunction() throws Exception {
    String script = ""
        + "FUNCTION = 'foo'\n"
        + "def foo(a):\n"
        + "    return 'bar'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Error: 'FUNCTION' is not a callable function"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorWhenTooManyPythonArgs() throws Exception {
    // python code takes 3 args, INI file only takes one
    String script = ""
        + "def function(a, b, c):\n"
        + "    return 'bar'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Error: Your python function takes 3 positional arguments"),
        containsString("your INI file specifies 1 'argument-types'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorWhenNotEnoughPythonArgs() throws Exception {
    // python code takes no args, INI file says it takes one
    String script = ""
        + "def function():\n"
        + "    return 'bar'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L)));

    assertThat(render(ex), allOf(
        containsString("Error: Your python function takes 0 positional or keyword arguments"),
        containsString("your INI file specifies 1 'argument-types'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

  @Test
  public void getSensibleErrorWithKeywordArgs() throws Exception {
    // our wrapper script doesn't support python **kwargs - it doesn't specify any
    // keyword names when calling the user's function, so they have to be positional args
    String script = ""
        + "def function(foo, **kwargs):\n"
        + "    return 'bar'\n"
        + "\n";

    IdentifiedFunction function = makeFunction(script,
        Arrays.asList(Types.INTEGER, Types.INTEGER),
        Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> call(function, Arrays.asList(1L, 2L)));

    assertThat(render(ex), allOf(
        containsString("Error: Your python function takes 1 positional or keyword arguments"),
        containsString("your INI file specifies 2 'argument-types'"),
        not(containsString(CPythonSettings.DEFAULT_ADAPTOR_PATH)),
        not(containsString("serializer.py"))
    ));
  }

}
