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

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.function.BiConsumer;

import org.hamcrest.Description;
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeDiagnosingMatcher;

import com.google.common.collect.Lists;

import nz.org.riskscape.engine.Identified;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemCode;

public class ProblemMatchers {

  private static List<Matcher<?>> recordedMatchers = new LinkedList<>();
  private static Method called;

  /**
   * supplied to the callback of isProblem to give access to matching functions - this is slightly preferable to using
   * static imports, which might be hard to keep clear while making them concise.
   */
  public static class Recorder {

    /**
     * Match a problem argument with the given matcher.
     */
    public <T> T match(Matcher<T> matcher) {
      recordedMatchers.add(matcher);
      return null;
    }

    /**
     * Match anything, regardless of the argument (including null)
     */
    public <T> T any() {
      recordedMatchers.add(Matchers.anything());
      return null;
    }

    /**
     * Shortcut for matching equality
     */
    public <T> T eq(T value) {
      recordedMatchers.add(Matchers.equalTo(value));
      // return the value, this allows equality matching to primative values. these would otherwise
      // fail if we returned a null here becuase you can't unbox a null value.
      return value;
    }
  }

  private static Recorder recorder = new Recorder();

  static class Handler implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      called = method;

      if (args.length != recordedMatchers.size()) {
        throw new RuntimeException("api misuse - wrong number of matchers supplied");
      }
      return null;
    }
  }

  /**
   * Create a problem matcher that will match a problem created in a way that matches how it gets described in the
   * callback.
   *
   * Usage example:
   *
   * ```
   * isProblem(GeneralProblems.class, (r, problems) ->
   *   problems.operationNotSupported(r.any(), r.match(isSubClassOf(Object.class)))
   * ```
   *
   * This is only worth using when you don't want to, or can't, exactly match the given problem's arguments - it's
   * easier to rely on equals, but that's not always possible.
   *
   * @param factoryClass a {@link ProblemFactory} class - an instance of this is given to the callback
   * @param callback a callback which is used to capture the method called on the factory as well as allowing arguments
   * to be matched (via the recorder arg)
   * @return the matcher
   */
  public static <T extends ProblemFactory> Matcher<Problem> isProblem(
      Class<T> factoryClass,
      BiConsumer<Recorder, T> callback
  ) {

    Handler pf = new Handler();
    Object proxy = Proxy.newProxyInstance(ProblemMatchers.class.getClassLoader(), new Class[] {factoryClass}, pf);

    // this should always be empty at the start, otherwise something weird is going on.
    if (!recordedMatchers.isEmpty() || called != null) {
      throw new RuntimeException("api misuse - this method is not reentrant");
    }

    try {
      callback.accept(recorder, factoryClass.cast(proxy));
      return createMatcher(called, Lists.newArrayList(recordedMatchers));
    } finally {
      // clean up for the next call
      called = null;
      recordedMatchers.clear();
    }
  }

  private static Matcher<Problem> createMatcher(Method factoryMethod, List<Matcher<?>> argMatchers) {
    ProblemCode expectedCode = new ProblemFactoryProxy.JavaMethodCode(factoryMethod);

    return new TypeSafeDiagnosingMatcher<Problem>(Problem.class) {

      @Override
      public void describeTo(Description description) {
        description.appendText("problem with code ").appendValue(expectedCode.toKey()).appendText(" and args ");

        for (Matcher<?> matcher : argMatchers) {
          matcher.describeTo(description);
        }
      }

      @Override
      protected boolean matchesSafely(Problem item, Description mismatchDescription) {
        boolean match = true;
        if (!item.getCode().toKey().equals(expectedCode.toKey())) {
          mismatchDescription.appendText("actual code was ").appendValue(item.getCode().toKey());
          // bail now - no point checking the args if the problem code is different, it's going to be a meaningless
          // and give possibly confusing mismatch output
          return false;
        }

        // this shouldn't happen with proper use of the APIs
        if (argMatchers.size() != item.getArguments().length) {
          throw new RuntimeException("unexpected error - matcher length mismatch");
        }

        int i = 0;
        for (Matcher<?> matcher : argMatchers) {
          Object actualArg = item.getArguments()[i++];

          if (!matcher.matches(actualArg)) {
            matcher.describeMismatch(actualArg, mismatchDescription);
            match = false;
          }
        }

        return match;
      }
    };
  }

  /**
   * @return a matcher suitable for matching something in a problem's arguments.  It might actually be an Identified
   * object, or it might be something supplied via ProblemPlaceholder - this method doesn't care.
   */
  public static <T> Matcher<T> namedArg(Class<T> type, String name) {
    return new DiagnosingMatcher<T>() {

      @Override
      public void describeTo(Description description) {
        description.appendText("object of type ").appendValue(type).appendText(" and named ").appendValue(name);
      }

      @Override
      protected boolean matches(Object item, Description mismatchDescription) {
        Class<?> actualType;
        String actualId;
        boolean match = true;
        if (item instanceof ProblemPlaceholder) {
          ProblemPlaceholder placeholder = (ProblemPlaceholder) item;
          actualType = placeholder.getWrappedClass();
          actualId = placeholder.getId();
        } else if (item instanceof Identified) {
          Identified identified = (Identified) item;
          actualId = identified.getId();
          actualType = ((Identified) item).getIdentifiedClass();
        } else if (item instanceof Parameter) {
          // special case - if we get more of these we might want to consider some sort of LocalName interface
          actualId = ((Parameter) item).getName();
          actualType = Parameter.class;
        } else {
          mismatchDescription.appendText("not identified or a problemplaceholder, was ").appendValue(item.getClass());
          return false;
        }

        if (!actualId.equals(name)) {
          mismatchDescription.appendText("with id ").appendValue(actualId);
          match = false;
        }

        if (!actualType.equals(type)) {
          mismatchDescription.appendText("of type ").appendValue(actualType);
          match = false;
        }

        return match;
      }
    };
  }

  /**
   * Builds a matcher that will search through a problem and its children, looking for the given list of matchers in
   * BFS order.  This allows you to match only the problems you are interested in and ignore any surrounding or
   * unimportant (for the purposes of your test) guff.  This gives us a nicer matching API than using hasAncestorProblem
   * and equalsIgnoringChildren
   *
   * Note that this method is not appropriate if you want to look for a tree of problems, rather than an individual, as
   * each problem being matched is stripped of its children before it is compared against a matcher.
   *
   * @param matchers a list of matchers that will be used when searching.  The Nth problem will be matched before the
   * Nth+1 problem.  Put another way, problems at the beginning of the list will be matched closer to the root of the
   * tree than the base
   */
  @SafeVarargs
  public static Matcher<Problem> problemsInTree(Matcher<Problem>... matchers) {

    return new TypeSafeDiagnosingMatcher<Problem>(Problem.class) {

      @Override
      public void describeTo(Description description) {
        description.appendText("problem hierarchy including:");
        for (Matcher<Problem> matcher : matchers) {
          description.appendText(" ").appendDescriptionOf(matcher);
        }
      }

      @Override
      protected boolean matchesSafely(Problem item, Description mismatchDescription) {
        LinkedList<Problem> stack = new LinkedList<>();
        stack.add(item);

        LinkedList<Matcher<Problem>> lineage = Lists.newLinkedList(Arrays.asList(matchers));

        mismatchDescription.appendText("not found amongst: ");

        Matcher<Problem> currentMatcher = lineage.removeFirst();

        while (!stack.isEmpty()) {
          Problem ptr = stack.removeFirst();
          stack.addAll(ptr.getChildren());
          // this matcher ignores the hierarchy
          ptr = ptr.setChildren(Collections.emptyList());

          mismatchDescription.appendText("\n  ").appendValue(ptr);

          if (currentMatcher.matches(ptr)) {
            if (lineage.isEmpty()) {
              return true;
            } else {
              currentMatcher = lineage.removeFirst();
            }
          }
        }

        return false;
      }
    };
  }

}
