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

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

import java.net.MalformedURLException;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;

import org.geotools.api.referencing.FactoryException;
import org.geotools.gce.imagemosaic.catalog.index.Indexer.Coverages.Coverage;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.referencing.CRS;
import org.geotools.referencing.crs.DefaultGeographicCRS;
import org.junit.Before;
import org.junit.Test;

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

import nz.org.riskscape.config.ConfigSection;
import nz.org.riskscape.config.ini.IniConfig;
import nz.org.riskscape.dsl.Lexer;
import nz.org.riskscape.dsl.SourceLocation;
import nz.org.riskscape.dsl.Token;
import nz.org.riskscape.engine.Identified;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.auth.HttpHeaderSecret;
import nz.org.riskscape.engine.auth.SecretBuilder;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.bind.ParameterProperties;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.function.FunctionArgument;
import nz.org.riskscape.engine.function.IdentifiedFunction;
import nz.org.riskscape.engine.function.JavaFunction;
import nz.org.riskscape.engine.function.RiskscapeFunction;
import nz.org.riskscape.engine.function.maths.Log;
import nz.org.riskscape.engine.i18n.DefaultObjectRenderer;
import nz.org.riskscape.engine.i18n.ObjectRenderer;
import nz.org.riskscape.engine.i18n.ResourceClassLoader;
import nz.org.riskscape.engine.pipeline.NullStep;
import nz.org.riskscape.engine.pipeline.RealizedStep;
import nz.org.riskscape.engine.problem.ProblemPlaceholder;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.resource.StringResource;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Struct.StructMember;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.ini.IniFile;
import nz.org.riskscape.ini.IniParser2;
import nz.org.riskscape.rl.ast.PipelineDeclaration;
import nz.org.riskscape.rl.ast.StepDeclaration;
import nz.org.riskscape.rl.ast.StepDefinition;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.rl.TokenTypes;
import nz.org.riskscape.rl.ast.BinaryOperation;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.FunctionCall;

public class ObjectRendererTest extends TerminalTestHelper {

  DefaultObjectRenderer objectRenderer;

  @Before
  public void before() throws MalformedURLException {
     messages.getClassLoader().append(ObjectRendererTest.class,
      new ResourceClassLoader("", Paths.get("src", "test", "resources", "test_i18n").toUri().toURL()));

     objectRenderer = new DefaultObjectRenderer();
  }

  private String render(Object thing) {
    return objectRenderer.render(messages, thing);
  }

  @Test
  public void canRenderToken() {
    Token token = Token.token(TokenTypes.IDENTIFIER, "foo");
    assertThat(render(token), is("'foo'"));
    assertThat(render(Token.UNKNOWN_LOCATION), is("<unknown location>"));

    // Use the lexer to generate some RL tokens.
    // Quoted identifiers and strings are rendered without quotes, but they're still
    // identifiable to the user, so we can live with that
    // also shows that line nos are included for multi line source
    Lexer<TokenTypes> lexer = new Lexer<>(TokenTypes.tokens(), "\"foo:bar\" \n 'baz ...'");
    assertThat(render(lexer.next()), is("'foo:bar' on line 1 (column 1)"));
    assertThat(render(lexer.next()), is("'baz ...' on line 2 (column 2)"));
  }

  @Test
  public void canRenderIdentifiedObject() {
    JavaFunction function = JavaFunction.withId("foo");

    // check we render it nicely (something other than it's toString())
    assertThat(function.toString(), not("'foo' function"));
    assertThat(render(function), is("'foo' function"));

    Bookmark bookmark = Bookmark.fromURI(URI.create("https://riskscape.org.nz"));
    assertThat(render(bookmark), is("'https://riskscape.org.nz' bookmark"));
  }

  @Test
  public void labelForAClassIsDistinctFromAnObject() throws Exception {
    // TODO sort of by accident, the objectRenderer isare looking up labels from the problems resources - we should
    // probably not do this
    RiskscapeFunction function = JavaFunction.withIdAndSource("foo", "blah.txt");
    assertThat(render(function), equalTo("'foo' function (from source blah.txt)"));
    assertThat(render(RiskscapeFunction.class), equalTo("riskscape function"));
  }

  @Test
  public void canRenderIdentifiedFunction() {
    JavaFunction function = JavaFunction.withIdAndSource("foo", URI.create("file:/blah.txt"));
    // check we render the source info for the function as well
    assertThat(render(function), is("'foo' function (from source file:/blah.txt)"));

    IdentifiedFunction logFunction = new Log();
    // check we don't display "(from source internal)" for built-in functions
    // as it's fairly meaningless to a user
    assertThat(render(logFunction), is("'log' function"));
  }

  @Test
  public void canRenderProblemPlaceholder() {
    JavaFunction function = JavaFunction.withId("foo");
    ProblemPlaceholder placeholder = new ProblemPlaceholder("foo", JavaFunction.class);

    // check we render an ProblemPlaceholder the same as an Identified object
    assertEquals(render(function), render(placeholder));

    // check we can render a class that doesn't implement Identified
    placeholder = new ProblemPlaceholder("bar", Type.class);
    assertEquals("'bar' type", render(placeholder));
  }

  @Test
  public void canRenderNull() {
    // cast so it doesn't call the Problem version in TerminalTestHelper
    assertThat(render((Object) null), is("null"));
  }

  @Test
  public void canRenderClasses() {
    assertThat(render(Relation.class), is("relation"));
    // class names that contain multiple words should get spaces inserted
    assertThat(render(ObjectRenderer.class), is("object renderer"));
  }

  @Test
  public void canRenderIterables() {
    List<Class<?>> testList = Lists.newArrayList(Relation.class, Coverage.class);
    assertThat(render(testList), is("[relation, coverage]"));

    assertThat(render(Lists.newArrayList(Coverage.class)),
        is("[coverage]"));

    assertThat(render(new LinkedHashSet<>(testList)),
        is("[relation, coverage]"));
  }

  @Test
  public void canRenderPaths() {
    Path path = Paths.get("/foo/bar.txt");
    assertThat(render(path), is("/foo/bar.txt"));
  }

  @Test
  public void canRenderFunctionArgs() {
    // function args are zero-based, but the user doesn't know that
    FunctionArgument arg = new FunctionArgument(0, Types.BOOLEAN);
    assertThat(render(arg), is("function argument 1"));
    FunctionArgument arg2 = new FunctionArgument("foo", Types.ANYTHING);
    assertThat(render(arg2), is("'foo' function argument"));

    ExpressionParser parser = new ExpressionParser();
    FunctionCall.Argument arg3 = new FunctionCall.Argument(parser.parse("{ foo: 'bar'}"));
    assertThat(render(arg3), is("function call argument '{foo: 'bar'}'"));
  }

  @Test
  public void canRenderBookmark() {
    URI location = URI.create("foo.txt");
    Bookmark bookmark = Bookmark.builder().id("bar").location(location).build();
    assertThat(render(bookmark), is("'bar' bookmark in location foo.txt"));
    // if location isn't adding value, don't bother displaying it
    bookmark = Bookmark.fromURI(location);
    assertThat(render(bookmark), is("'foo.txt' bookmark"));
  }

  @Test
  public void canRenderParameter() {
    Parameter param = Parameter.required("foo", String.class);
    assertThat(render(param), is("'foo' parameter"));
  }

  @Test
  public void canRenderStructMember() {
    Struct struct = Struct.of("foo", Types.TEXT, "bar", Types.ANYTHING);
    StructMember foo = struct.getMember("foo").get();
    assertThat(render(foo), is("'foo: Text' struct member"));
    StructMember bar = struct.getMember("bar").get();
    assertThat(render(bar), is("'bar: Anything' struct member"));
  }

  @Test
  public void canRenderExpression() throws Exception {
    ExpressionParser parser = new ExpressionParser();
    // note .toRCQL() strips unnecessary whitespace
    Expression funcCall = parser.parse("foo( 'bar ' )");
    Expression list = parser.parse("[1, 2 , 3, 4, 5 ]");

    assertThat(render(funcCall), is("expression 'foo('bar ')'"));
    assertThat(render(list), is("expression '[1, 2, 3, 4, 5]'"));

    Expression propAccess = parser.parse("foo.bar");
    assertThat(render(propAccess), is("expression 'foo.bar'"));
    Expression oper = parser.parse("foo.bar + 2 + 3 + 4 + 5");
    assertThat(render(((BinaryOperation) oper).getRhs()), is("expression '2 + 3 + 4 + 5'"));
  }

  @Test
  public void canRenderSteps() {
    assertThat(render(NullStep.INSTANCE), is("'null' step"));
  }

  @Test
  public void canRenderAnAnonymousStepDefinition() {
    StepDefinition stepDefinition = ExpressionParser.INSTANCE.parsePipeline("""
        # this is a comment
        foo(
         {bar, [
         baz]}
        )
        """).stepDefinitionIterator().next();

    // line refers to the step implementation id
    assertThat(render(stepDefinition), is("`foo` step on line 2"));
  }

  @Test
  public void canRenderANamedStepDefinition() {
    StepDefinition stepDefinition = ExpressionParser.INSTANCE.parsePipeline("""
        foo(
          bar
        ) as cool_foo
        """).stepDefinitionIterator().next();
    assertThat(render(stepDefinition), is("`foo` step (cool_foo) on line 1"));
  }

  @Test
  public void canRenderAStepReference() {
    StepDeclaration stepReference = ExpressionParser.INSTANCE.parsePipeline("""
        foo() as bar
        bar() as baz
        bar -> baz
        """).getLast().getLast();
    assertThat(render(stepReference), is("'baz' step reference"));
  }

  @Test
  public void canRenderARealizedStep() {
    // should just render the same as StepDefinition
    assertThat(render(RealizedStep.named("foo")), is("`foo` step"));
  }

  @Test
  public void canRenderEnvelope() throws FactoryException {
    ReferencedEnvelope envelope =
        new ReferencedEnvelope(-45.001, -46.0, 168.1, 170.12345, CRS.decode("EPSG:4326"));
    assertThat(render(envelope), is("EPSG:4326 [-46.0 : -45.001 North, 168.1 : 170.1234 East]"));
  }

  @Test
  public void canRenderCrs() throws FactoryException {
    assertThat(render(DefaultGeographicCRS.WGS84), is("EPSG:4326"));
    assertThat(render(CRS.decode("EPSG:2193")), is("EPSG:2193"));
  }

  @Test
  public void canRenderPipelineDeclaration() {
    List<String> dsl = Arrays.asList("input('foo') as bar", " -> select({*}) as baz");
    // rendered DSL should be nicely formatted with newlines, even if the original source wasn't
    String sourceDsl = String.join("", dsl);
    String expectedDsl = String.join(OsUtils.LINE_SEPARATOR, dsl);
    PipelineDeclaration pipelineDecl = ExpressionParser.INSTANCE.parsePipeline(sourceDsl);
    assertThat(render(pipelineDecl), is("pipeline source '" + expectedDsl + "'"));
  }

  @Test
  public void canRenderIniNgSection() throws Exception {
    IniFile ini = new IniParser2().parse("""
        [bookmark foo]
        location = blah.txt
    """);

    IniConfig config = IniConfig.fromIniFile(URI.create("file:/foo.txt"), ini);
    ConfigSection section = config.getSection("bookmark foo").get();
    assertThat(render(section), is("INI file section '[bookmark foo]' on line 1 of /foo.txt"));
  }

  @Test
  public void canRenderRange() {
    assertThat(render(Range.singleton(2)), is("2"));
    assertThat(render(Range.closed(1, 2)), is("1-2"));
    assertThat(render(Range.atLeast(1)), is("1+"));
    // we don't really use unbounded lower in the code, but we shouldn't crash
    assertThat(render(Range.atMost(1)), is(Range.atMost(1).toString()));
  }

  @Test
  public void canRenderParameterProperty() {
    assertThat(render(ParameterProperties.MIN_VALUE), is("'min' parameter property"));
  }

  @Test
  public void canRenderURI() throws Exception {
    assertThat(render(URI.create("http://google.com")), equalTo("http://google.com"));
    assertThat(
        render(URI.create("https://riskscape.org.nz/foo?bar=baz#introduction")),
        equalTo("https://riskscape.org.nz/foo?bar=baz#introduction")
    );
  }

  @Test
  public void canRenderFileURI() throws Exception {
    assertThat(render(URI.create("file:///foo/bar.txt")), equalTo("/foo/bar.txt"));
    assertThat(render(URI.create("file:/foo/baz.txt")), equalTo("/foo/baz.txt"));
    assertThat(render(URI.create("file:/foo/baz.txt")), equalTo("/foo/baz.txt"));
  }

  @Test
  public void canRenderURIWithSourceCodeInfo() throws Exception {
    assertThat(
        render(new SourceLocation(5, 6, 7).addToUri(URI.create("file:///foo/bar.txt"))),
        equalTo("line 6 of /foo/bar.txt")
    );

    // unlined
    assertThat(
        render(SourceLocation.unlined(5).addToUri(URI.create("file:///foo/bar.txt"))),
        equalTo("/foo/bar.txt (Index: 6)")
    );

    // check that addToUri replaces existing anchor fragment
    assertThat(
        render(SourceLocation.unlined(5).addToUri(URI.create("file:///foo/bar.txt#FOO"))),
        equalTo("/foo/bar.txt (Index: 6)")
    );
  }

  @Test
  public void canRenderHttpSecret() throws Exception {
    HttpHeaderSecret secret = mock(HttpHeaderSecret.class);
    when(secret.getSecretHeaderValue()).thenReturn("do-not-leak");
    when(secret.getId()).thenReturn("my-secret");
    when(secret.getFramework()).thenReturn("secure");

    // with no location
    when(secret.getDefinedIn()).thenReturn(Resource.UNKNOWN_URI);
    assertThat(
        render(secret),
        equalTo("'my-secret' secure secret")
    );

    // now with a location
    when(secret.getDefinedIn()).thenReturn(
        URI.create("file:///home/tester/.config/riskscape/secrets.ini#I0L1C1")
    );
    assertThat(
        render(secret),
        equalTo("'my-secret' secure secret from line 1 of /home/tester/.config/riskscape/secrets.ini")
    );

    // sanity check there was no snooping on the secret value
    verify(secret, never()).getSecretHeaderValue();
  }

  public void canRenderFileUriResourceWithUriContainingSourceCodeInfo() throws Exception {
    StringResource resource = new StringResource(
        new SourceLocation(4, 3, 2).addToUri(URI.create("file:///foo/project.ini")),
        "some string");

    assertThat(render(resource), equalTo("line 3 of /foo/project.ini"));
  }

  @Test
  public void canRenderHttpUriResourceWithUriContainingSourceCodeInfo() throws Exception {
    StringResource resource = new StringResource(
        new SourceLocation(4, 6, 2).addToUri(URI.create("http://foo/project.ini")),
        "some string");

    assertThat(render(resource), equalTo("line 6 of http://foo/project.ini"));
  }

  @Test
  public void canRenderSecretBuilder() throws Exception {
    assertThat(render(SecretBuilder.class), equalTo("secrets framework"));
  }

  @Test
  public void canRenderAnonymousClass() throws Exception {
    Identified anon = new Identified() {
      @Override
      public String getId() {
        return "foo";
      }
    };
    // previously we would render an empty string here, which was confusing
    assertNotEquals("", render(anon.getClass()));
    // just displaying where the anonymous class was defined is probably the best we can do
    assertThat(render(anon.getClass()), equalTo("object renderer test$1"));
  }

}
