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

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

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;

import org.junit.Before;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.mockito.stubbing.OngoingStubbing;

import nz.org.riskscape.engine.Assert;
import nz.org.riskscape.engine.Matchers;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.bind.impl.BookmarkBinder;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.relation.EmptyRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;

public class DefaultBindingContextTest extends ProjectTest {

  ParameterBinder mockBinder = Mockito.mock(ParameterBinder.class);

  @Before
  public void setup() {
    when(mockBinder.getId()).thenReturn("mock");
    engine.getBinders().clear();
    engine.getBinders().add(mockBinder);
    // construct *after* binders are added
    bindingContext = new DefaultBindingContext(project, p -> sunkProblems.add(p));
  }

  @Test
  public void testBindObjectClass() throws Exception {

    when(mockBinder.canBind(String.class, String.class)).thenReturn(true);

    // simple case
    whenBind("foo", String.class, "bar");
    assertThat(
      bindingContext.bind("foo", String.class),
      Matchers.result("bar")
    );


    // problems when binding
    whenBind("bar", String.class, GeneralProblems.get().required("bars"));
    assertThat(
      bindingContext.bind("bar", String.class),
      Matchers.failedResult(equalTo(GeneralProblems.get().required("bars")))
    );

    // no binder

    NoBindingAvailableException ex =
      Assert.assertThrows(NoBindingAvailableException.class, () -> bindingContext.bind("foo", Long.class));
    assertEquals(String.class, ex.getFrom());
    assertEquals(Long.class, ex.getTo());
    assertNull(ex.getParameter());
    assertEquals(
        ex.getProblem(),
        ParamProblems.get().noBindingAvailable(String.class, Long.class)
      );
  }

  @Test
  public void testBindObjectParameter() throws Exception {
    when(mockBinder.canBind(String.class, String.class)).thenReturn(true);
    Parameter parameter = Parameter.required("foo-param", String.class);

    // simples
    whenBind("foo", String.class, "bar");
    assertThat(
      bindingContext.bind("foo", parameter),
      Matchers.result("bar")
    );

    // failure case - note the extra problem handling
    whenBind("bar", String.class, GeneralProblems.get().required("cool stories"));
    assertThat(
      bindingContext.bind("bar", parameter),
      Matchers.failedResult(equalTo(
          ParamProblems.get().bindingError(parameter, GeneralProblems.get().required("cool stories"))
      ))
    );

    // no binder
    Parameter otherParameter = Parameter.required("foo", BigDecimal.class);
    NoBindingAvailableException ex =
      Assert.assertThrows(NoBindingAvailableException.class, () -> bindingContext.bind(1L, otherParameter));

    assertEquals(Long.class, ex.getFrom());
    assertEquals(BigDecimal.class, ex.getTo());
    assertEquals(otherParameter, ex.getParameter());
    assertEquals(
      ex.getProblem(),
      ParamProblems.get().noBindingAvailableForParameter(otherParameter, Long.class, BigDecimal.class)
    );
  }

  @Test
  public void testBindParameterWithValidation() throws Exception {
    when(mockBinder.canBind(String.class, String.class)).thenReturn(true);
    Parameter parameter = Parameter.required("foo-param", String.class)
        .withValidation((c, val) -> val.equals("bar") ? Collections.emptyList()
            : Arrays.asList(GeneralProblems.get().required("cool stories")));

    whenBind("foo", String.class, "bar");
    assertThat(bindingContext.bind("foo", parameter), Matchers.result("bar"));

    // !bar => validation error
    whenBind("baz", String.class, "qux");
    assertThat(bindingContext.bind("baz", parameter), Matchers.resultWithProblems(is("qux"), contains(
        is(ParamProblems.get().bindingError(parameter, GeneralProblems.get().required("cool stories")))
    )));
  }

  @Test
  public void testMostRecentlyAddedBinderTakesPriority() {
    // add a couple more, mockBinder should be lowest down the list
    ParameterBinder secondMockBinder = mock(ParameterBinder.class);
    bindingContext.addBinder(secondMockBinder);
    ParameterBinder thirdMockBinder = mock(ParameterBinder.class);
    bindingContext.addBinder(thirdMockBinder);

    // start off with only the first binder being applicable
    when(mockBinder.canBind(String.class, Integer.class)).thenReturn(true);
    when(mockBinder.bindValue(bindingContext, "foo", Integer.class)).thenReturn(ResultOrProblems.of(1));

    assertThat(
        bindingContext.bind("foo", Integer.class),
        Matchers.result(1)
    );

    // check that testing canBind happened in the right order...
    InOrder inOrder = inOrder(thirdMockBinder, secondMockBinder, mockBinder);
    inOrder.verify(thirdMockBinder).canBind(String.class, Integer.class);
    inOrder.verify(secondMockBinder).canBind(String.class, Integer.class);
    inOrder.verify(mockBinder).canBind(String.class, Integer.class);

    // ... and that only the mockBinder got a call
    verify(mockBinder).bindValue(bindingContext, "foo", Integer.class);
    verify(secondMockBinder, never()).bindValue(bindingContext, "foo", Integer.class);
    verify(thirdMockBinder, never()).bindValue(bindingContext, "foo", Integer.class);


    /*
     * Now first and second canBind
     */
    clearInvocations(mockBinder, secondMockBinder, thirdMockBinder);

    when(secondMockBinder.canBind(String.class, Integer.class)).thenReturn(true);
    when(secondMockBinder.bindValue(bindingContext, "foo", Integer.class)).thenReturn(ResultOrProblems.of(2));

    assertThat(
        bindingContext.bind("foo", Integer.class),
        Matchers.result(2)
    );

    // this time third and second got tested...
    inOrder = inOrder(thirdMockBinder, secondMockBinder, mockBinder);
    inOrder.verify(thirdMockBinder).canBind(String.class, Integer.class);
    inOrder.verify(secondMockBinder).canBind(String.class, Integer.class);
    // first didn't - it can bind, but is below second
    verify(mockBinder, never()).canBind(String.class, Integer.class);

    // confirm only the second binder did the binding
    verify(secondMockBinder).bindValue(bindingContext, "foo", Integer.class);
    verify(mockBinder, never()).bindValue(bindingContext, "foo", Integer.class);
    verify(thirdMockBinder, never()).bindValue(bindingContext, "foo", Integer.class);

    /*
     * All of them can now bind
     */
    clearInvocations(mockBinder, secondMockBinder, thirdMockBinder);

    when(thirdMockBinder.canBind(String.class, Integer.class)).thenReturn(true);
    when(thirdMockBinder.bindValue(bindingContext, "foo", Integer.class)).thenReturn(ResultOrProblems.of(3));

    assertThat(
        bindingContext.bind("foo", Integer.class),
        Matchers.result(3)
    );

    // only the third gets asked - it was the last in and so takes priority over both
    verify(thirdMockBinder).canBind(String.class, Integer.class);
    verify(secondMockBinder, never()).canBind(String.class, Integer.class);
    verify(mockBinder, never()).canBind(String.class, Integer.class);

    verify(thirdMockBinder).bindValue(bindingContext, "foo", Integer.class);
    verify(secondMockBinder, never()).bindValue(bindingContext, "foo", Integer.class);
    verify(mockBinder, never()).bindValue(bindingContext, "foo", Integer.class);
  }

  @Test
  public void bindingToDataIsPossibleViaExectionContext() throws Exception {
    Relation relation = new EmptyRelation(Types.TEXT.asStruct());
    addPickledData("bookmark", relation);
    // ResolvedBookmarkBinder depends on this
    engine.add(new BookmarkBinder());

    BindingContext contextFromProject = project.newBindingContext();
    BindingContext contextFromExecCtx = executionContext.getBindingContext();

    // nope
    Problems problems = Assert.assertThrows(RiskscapeException.class,
        () -> contextFromProject.bind("bookmark", ResolvedBookmark.class)).getProblems();
    assertThat(
        problems,
        equalTo(ParamProblems.get().noBindingAvailable(String.class, ResolvedBookmark.class))
    );

    // yep
    assertThat(
        contextFromExecCtx
          .bind("bookmark", ResolvedBookmark.class)
          .flatMap(rb -> rb.getData(Relation.class)),
        Matchers.result(relation)
    );
  }

  private void whenBind(Object rawValue, Class<?> toType, Object andReturn) {
    OngoingStubbing<ResultOrProblems<?>> stubbing =
        when(mockBinder.bindValue(same(bindingContext), eq(rawValue), eq(toType)));

    if (andReturn instanceof Problem) {
      stubbing.thenReturn(ResultOrProblems.failed((Problem) andReturn));
    } else {
      stubbing.thenReturn(ResultOrProblems.of(andReturn));
    }
  }
}
