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

import static nz.org.riskscape.engine.Matchers.*;
import static nz.org.riskscape.engine.TupleMatchers.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;

import nz.org.riskscape.engine.RelationMatchers;
import nz.org.riskscape.engine.TemporaryDirectoryTestHelper;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkProblems;
import nz.org.riskscape.engine.data.BookmarkResolver;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.function.IdentifiedFunction;
import nz.org.riskscape.engine.relation.EmptyRelation;
import nz.org.riskscape.engine.relation.ListRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.types.RelationType;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.TypeProblems;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.types.WithMetadata;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.ExpressionProblems;

public class RelationInputStepTest extends BaseStepTest<RelationInputStep> implements TemporaryDirectoryTestHelper {

  @Override
  protected RelationInputStep createStep() {
    return new RelationInputStep(engine);
  }

  ListRelation values = ListRelation.ofValues("foo");

  // Additional test data for bookmark resolving tests
  Path tmpdir;
  TypedCoverage coverage = TypedCoverage.empty(Types.INTEGER);
  ResolvedBookmark resolvedCoverage;
  Relation relation = new EmptyRelation(Types.TEXT.asStruct());
  ResolvedBookmark resolvedRelation;
  BookmarkResolver mockResolver = mock(BookmarkResolver.class);
  ArgumentCaptor<Bookmark> captor = ArgumentCaptor.forClass(Bookmark.class);
  Problem problem = Problems.foundWith("Test");

  @Before
  public void setupRelation() throws IOException, URISyntaxException {
    addPickledData("foo", values);

    // Setup for bookmark resolving tests
    this.tmpdir = createTempDirectory("relationInputStep");
    resolvedCoverage = addPickledData("test-coverage", coverage);
    resolvedRelation = addPickledData("test-relation", relation);

    when(mockResolver.getId()).thenReturn("mock-resolver");
    engine.getBookmarkResolvers().add(mockResolver);
  }

  @After
  public void teardown() throws Exception {
    this.remove(tmpdir);
  }

  @Test
  public void producesTheRelationFromParameters() {
    addParam("relation", "'foo'");
    assertThat(
         step.realizeSimple(input()),
         result(sameInstance(values)));
  }

  @Test
  public void canOptionallyNestTuples() throws Exception {
    addParam("relation", "'foo'");
    addParam("name", "nest-me");
    Struct expected = Types.TEXT.asStruct().parent("nest-me");

    assertThat(step.realizeSimple(input()),
        result(Relation.class, RelationMatchers.relationWithTuples(Tuple.ofValues(expected,
            Tuple.ofValues(Types.TEXT.asStruct(), "foo")))));
  }

  @Test
  public void canOptionallyApplyALimit() throws Exception {
    addParam("relation", "'foo'");
    addParam("limit", 3L);

    Relation result = (Relation) step.realizeSimple(input()).get();
    assertEquals(3L, result.getLimit());
    assertEquals(0, result.getOffset());
  }

  @Test
  public void canOptionallyApplyAnOffset() throws Exception {
    addParam("relation", "'foo'");
    addParam("offset", 2L);

    Relation result = (Relation) step.realizeSimple(input()).get();
    assertEquals(Long.MAX_VALUE, result.getLimit());
    assertEquals(2L, result.getOffset());

  }

  @Test
  public void canOptionallyApplyALimitAndOffset() throws Exception {
    addParam("relation", "'foo'");
    addParam("offset", 2L);
    addParam("limit", 3L);

    Relation result = (Relation) step.realizeSimple(input()).get();
    assertEquals(2L, result.getOffset());
    assertEquals(3L, result.getLimit());
  }

  @Test
  public void canBeAnExpressionThatYieldsABookmark() throws Exception {
    // need a pickled version of the LookupBookmark function  (it's in a different package)
    project.getFunctionSet().add(new IdentifiedFunction() {

      @Override
      public List<Type> getArgumentTypes() {
        return Arrays.asList(Types.TEXT);
      }

      @Override
      public Type getReturnType() {
        return WithMetadata.wrap(RelationType.WILD, project.getBookmarks().get("foo"));
      }

      @Override
      public Object call(List<Object> args) {
        return values;
      }

      @Override
      public String getId() {
        return "bookmark";
      }

      @Override
      public String getDescription() {
        return null;
      }

      @Override
      public String getSource() {
        return null;
      }

      @Override
      public Category getCategory() {
        return Category.LANGUAGE;
      }
    });

    addParam("relation", "bookmark('foo')");
    assertThat(step.realizeSimple(input()), result(sameInstance(values)));
  }

  @Test
  public void canOptionallySupplyANestedValueInsteadOfARelation() throws Exception {
    addParam("value", "'foo'");
    addParam("name", "bar");

    assertThat(
      step.realizeSimple(input()),
      result(Relation.class, RelationMatchers.relationWithTuples(
        contains(tupleWithValue("bar",  equalTo("foo")))
      ))
    );
  }

  @Test
  public void valueCanBeASingleTuple() throws Exception {
    addParam("value", "{foo: 'bar', baz: 1}");

    assertThat(
      step.realizeSimple(input()),
      result(Relation.class, RelationMatchers.relationWithTuples(contains(
        allOf(
          tupleWithValue("foo", equalTo("bar")),
          tupleWithValue("baz", equalTo(1L))
        )
      )))
    );
  }

  @Test
  public void valueCanBeASingleNestedTuple() throws Exception {
    addParam("value", "{foo: 'bar', baz: 1}");
    addParam("name", "nested");

    assertThat(
      step.realizeSimple(input()),
      result(Relation.class, RelationMatchers.relationWithTuples(contains(
        tupleWithValue("nested",
          allOf(
            tupleWithValue("foo", equalTo("bar")),
            tupleWithValue("baz", equalTo(1L))
          )
        )
      )))
    );
  }

  @Test
  public void eitherValueOrRelationMustBeGiven() throws Exception {
    assertThat(
      step.realizeSimple(input()),
      failedResult(equalTo(ParamProblems.oneOfTheseRequired("relation", "value")))
    );

    addParam("value", "1");
    addParam("relation", "'foo'");

    assertThat(
      step.realizeSimple(input()),
      failedResult(equalTo(ParamProblems.get().mutuallyExclusive("relation", "value")))
    );

  }

  @Test
  public void givesAnErrorIfValueDoesNotRealize() throws Exception {
    Expression nonConstant = ExpressionParser.INSTANCE.parse("{foo: bar}");
    addParam("value", nonConstant.toSource());

    assertThat(
      step.realizeSimple(input()),
      failedResult(equalTo(
        Problems.foundWith("value", ExpressionProblems.get().constantRequired(nonConstant))))
    );
  }

  @Test
  public void givesWarningsIfLimitAndOffsetUsedWithAValue() throws Exception {
    addParam("value", ExpressionParser.INSTANCE.parse("'foo'"));
    addParam("limit", 1L);
    addParam("offset", 1L);

    ResultOrProblems<Relation> realizedOr = (ResultOrProblems<Relation>) step.realizeSimple(input());
    assertThat(realizedOr, resultWithProblems(
        RelationMatchers.relationWithTuples(
          contains(tupleWithValue("value", equalTo("foo")))
        ),
        containsInAnyOrder(
          ParamProblems.get().ignored("limit").withSeverity(Severity.WARNING),
          ParamProblems.get().ignored("offset").withSeverity(Severity.WARNING)
       ))
    );
  }

  // Bookmark resolving test cases - similar to BookmarkBinderTest

  @Test
  public void willBindRelationFromBookmarkIfIdMatchesValue() {
    addParam("relation", "'test-relation'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));
  }

  @Test
  public void willBindSingleQuotedBookmarkIdentifier() {
    // single quotes are stripped
    addParam("relation", "'test-relation'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));
  }

  @Test
  public void willBindRelationFromBookmarkIfCoverageIsConvertible() {
    TypedCoverage convertibleCoverage = new TypedCoverage.Empty(Types.INTEGER) {
      @Override
      public Optional<Relation> asRelation() {
        return Optional.of(relation);
      }
    };
    addPickledData("convertible-coverage", convertibleCoverage);

    addParam("relation", "'convertible-coverage'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));
  }

  @Test
  public void willThrowIfTypeMismatch() throws Exception {
    addParam("relation", "'test-coverage'");

    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(allOf(
            hasAncestorProblem(equalTo(TypeProblems.get().mismatch(
                resolvedCoverage.getBookmark(), Relation.class, TypedCoverage.Empty.class)))
        ))));
  }

  @Test
  public void willCreateABookmarkOnTheFlyIfNoneRegistered() throws Exception {
    when(mockResolver.resolve(captor.capture(), any())).thenReturn(Optional.of(resolvedRelation));

    addParam("relation", "'unknown-bookmark'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));

    Bookmark created = captor.getValue();
    assertNotNull(created);
    assertTrue(created.getLocation().getPath().endsWith("unknown-bookmark"));
  }

  @Test
  public void willCreateABookmarkWithQueryParametersAsBookmarkOptionsIfNoneRegistered() throws Exception {
    when(mockResolver.resolve(captor.capture(), any())).thenReturn(Optional.of(resolvedRelation));

    addParam("relation", "'this-might-be-a-file.gdb?layer=foo'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));

    Bookmark created = captor.getValue();
    assertNotNull(created);
    assertTrue(created.getLocation().toString().endsWith("this-might-be-a-file.gdb?layer=foo"));
    assertEquals("layer=foo", created.getLocation().getQuery());
    assertEquals(Arrays.asList("foo"), created.getUnparsed().get("layer"));
  }

  @Test
  public void willThrowIfBookmarkHasValidationProblems() throws Exception {
    addPickledBookmark(ResolvedBookmark.withId("problematic", relation, List.of(), List.of(problem)));

    addParam("relation", "'problematic'");
    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(hasAncestorProblem(equalTo(problem)))));
  }

  @Test
  public void willThrowIfBookmarkHasDataProblems() throws Exception {
    ResolvedBookmark validationProblems =
        ResolvedBookmark.withId("problematic", relation, List.of(problem), List.of());

    addPickledBookmark(validationProblems);

    addParam("relation", "'problematic'");
    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(hasAncestorProblem(equalTo(problem)))));
  }

  @Test
  public void willThrowIfBookmarkHasMismatchingCoverageDataProblems() throws Exception {
    // covers the case where we want a relation, but it's a coverage - we want to show the original errors if they are
    // there
    ResolvedBookmark validationProblems =
        ResolvedBookmark.withId("problematic", coverage, List.of(problem), List.of());

    addPickledBookmark(validationProblems);

    addParam("relation", "'problematic'");
    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(hasAncestorProblem(equalTo(problem)))));
  }

  @Test
  public void willNotBindBookmarkFromInvalidExpression() {
    // valid bookmark ID, invalid expression - there's no closing ')'
    addParam("relation", "bookmark_test('test-relation'");

    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(equalIgnoringChildren(
            ExpressionProblems.get().malformedExpression("bookmark_test('test-relation'")
        ))));
  }

  @Test
  public void willNotBindBookmarkFromBadBookmarkId() {
    // valid expression, but bad bookmark ID
    addParam("relation", "'unknown-relation'");

    assertThat(
        step.realizeSimple(input()),
        failedResult(hasAncestorProblem(equalTo(BookmarkProblems.get().notBookmarkOrFile("unknown-relation")))));
  }

  @Test
  public void willCreateABookmarkFromExpressionOnTheFlyIfNoneRegistered() throws Exception {
    // use mocking to mimic turning a URI into a bookmark
    when(mockResolver.resolve(captor.capture(), any())).thenReturn(Optional.of(resolvedRelation));

    addParam("relation", "'foo.shp'");
    assertThat(
        step.realizeSimple(input()),
        result(sameInstance(relation)));

    Bookmark created = captor.getValue();
    assertNotNull(created);
    assertTrue(created.getLocation().getPath().endsWith("foo.shp"));
  }

}
