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

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import com.google.common.collect.Lists;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.Unchecked;
import nz.org.riskscape.engine.types.Anything;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.util.ListUtils;

/**
 * An optional-esque that contains the result of some result-yielding operation where it may have had problems
 * which might have stopped it from producing a result.
 *
 * Used as a functional alternative to using checked exceptions for user-facing errors, typically where the type of the
 * problem is irrelevant - something the user has done has meant we can't proceed and we need to pass it back to the
 * user with some context to help them fix it.
 *
 */
@Slf4j
public final class ResultOrProblems<T> {

  public static final List<Problem> NO_PROBLEMS = Collections.emptyList();

  public static <T> ResultOrProblems<T> of(@NonNull T thing) {
    return new ResultOrProblems<>(thing, NO_PROBLEMS);
  }

  public static  <T> ResultOrProblems<T> of(@NonNull T thing, Problem... problems) {
    return new ResultOrProblems<>(thing, problems.length == 0 ? NO_PROBLEMS : Arrays.asList(problems));
  }

  public static <T> ResultOrProblems<T> of(@NonNull T thing, List<Problem> withProblems) {
    return new ResultOrProblems<>(thing, withProblems.size() == 0 ? NO_PROBLEMS : withProblems);
  }

  /**
   * Constructor where thing may or may not have been successfully created.
   */
  public static <T> ResultOrProblems<T> ofNullable(T thing, List<Problem> withProblems) {
    return new ResultOrProblems<>(thing, withProblems);
  }

  /**
   * Create a new failed {@link ResultOrProblems} that collects all problems from a set of other
   * {@link ResultOrProblems}.  The caller must ensure that there are at least some error level problems among the
   * given arguments.
   *
   * @param <T> An arbitrary generic type
   * @param failures a collection of results with some failures
   * @return a new failed ResultOrProblems that includes all problems from the given problems
   * @throws IllegalArgumentException if there are no errors among the results
   */
  public static <T> ResultOrProblems<T> failed(Collection<ResultOrProblems<?>> failures) {
    if (failures.size() == 0) {
      throw new IllegalArgumentException("at least one failure must be given");
    }
    // most problems have a single failure, double the norm seems a reasonable compromise?
    List<Problem> collectedProblems = new ArrayList<Problem>(failures.size() * 2);
    for (ResultOrProblems<?> failure : failures)  {
      collectedProblems.addAll(failure.getProblems());
    }

    if (Problem.hasErrors(collectedProblems)) {
      return failed(collectedProblems);
    } else {
      throw new IllegalArgumentException("A failed result can not be constructed without error level problems");
    }
  }

  /**
   * Create a new failed {@link ResultOrProblems} that collects all problems from a set of other
   * {@link ResultOrProblems}.  The caller must ensure that there are at least some error level problems among the
   * given arguments.
   *
   * @param <T> An arbitrary generic type
   * @param firstFailure a possibly failed result
   * @param restFailures even more possibly failed results
   * @return a new failed ResultOrProblems that includes all problems from the given problems
   * @throws IllegalArgumentException if there are no errors among the results
   */
  public static <T> ResultOrProblems<T> failed(ResultOrProblems<?> firstFailure, ResultOrProblems<?>... restFailures)
    throws IllegalArgumentException {
    return failed(ListUtils.prepend(firstFailure, restFailures));
  }

    public static <T> ResultOrProblems<T> failed(Problem... withProblems) {
      return failed(Arrays.asList(withProblems));
  }

  public static <T> ResultOrProblems<T> failed(List<Problem> withProblems) {
    return new ResultOrProblems<>(null, withProblems.size() == 0 ? NO_PROBLEMS : withProblems);
  }

  /**
   * Shortcut to ResultOrProblems.failed(Problem.error)
   */
  public static <T> ResultOrProblems<T> error(String msg) {
    return error("%s", msg);
  }

  /**
    * Shortcut to ResultOrProblems.failed(Problem.error)
   */
  public static <T> ResultOrProblems<T> error(String msg, Object... args) {
    return ResultOrProblems.failed(Problem.error(msg, args));
  }

  public static <T> ResultOrProblems<T> error(RiskscapeException ex) {
    return failed(Problems.caught(ex));
  }

  public static ResultOrProblems<Anything> unchecked(String message) {
    return new ResultOrProblems<>(Types.ANYTHING, Arrays.asList(new Unchecked(message)));
  }

  private final T computedResult;

  private final Problems problems;

  public ResultOrProblems(T computedResult, List<Problem> problems) {
    this.computedResult = computedResult;
    this.problems = Problems.from(problems);

    int idx = 0;
    for (Problem problem : problems) {
      if (problem == null) {
        throw new NullPointerException("Problem " + idx + " is null");
      }
      idx++;
    }
  }

  public List<Problem> getProblems() {
    return problems.toList();
  }

  private void logIfWarningsIgnored() {
    if (problems.isPresent()) {
      Throwable t = new Throwable("");
      t.fillInStackTrace();
      log.warn("Non-error problems are present and have not been consumed - warnings are being hidden - ", t);
      assert false : "Warnings are being hidden";
    }
  }

  /**
   * @return the result of this {@link ResultOrProblems}
   * @throws ResultComputationException if no result was set, with this {@link ResultOrProblems} list of problems
   * attached to it.
   */
  public T get() {
    if (computedResult == null || hasErrors()) {
      throw new ResultComputationException(getProblems());
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }


  /**
   * Flow control helper for use with ResultOrProblems - see {@link ProblemException} for more details.
   *
   * @return a computed result if one is present, or throws a {@link ProblemException} with problems if none.
   * @throws ProblemException if there is no computed result
   */
  public T getOrThrow() throws ProblemException {
    if (computedResult == null || hasErrors()) {
      throw new ProblemException(problems);
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }

  /**
   * Flow control helper for use with ResultOrProblems - see {@link ProblemException} for more details.
   *
   * @param parent a problem to wrap around any problems that might be present
   * @return a computed result if one is present, or throws a {@link ProblemException} with problems if none.
   * @throws ProblemException if there is no computed result
   */
  public T getOrThrow(Problem parent) throws ProblemException {
    if (computedResult == null || hasErrors()) {
      throw new ProblemException(parent.withChildren(problems));
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }

  /**
   * Flow control helper for use with ResultOrProblems - see {@link ProblemException} for more details.  This variant
   * gives more control over the problem that ultimately gets included in the {@link ProblemException}.
   *
   * @param problemFunction a function that will produce a single problem for the ProblemException that is thrown.
   * @return a computed result if one is present, or throws a {@link ProblemException} with problems if none.
   * @throws ProblemException if there is no computed result
   */
  public T getOrThrow(Function<Problems, Problems> problemFunction) throws ProblemException {
    if (computedResult == null || hasErrors()) {
      throw new ProblemException(problemFunction.apply(problems));
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }

  /**
   * @return computedResult, regardless of any other conditions.  Will return a computedResult even if problems
   * occurred - this function is really here to allow debugging/messaging code to report back a semi-successful
   * object back to the user.
   */
  public T getWithProblemsIgnored() {
    return computedResult;
  }

  /**
   * Removes any warnings or lower from a successful {@link ResultOrProblems} so that get() doesn't produce a warning
   */
  public ResultOrProblems<T> drainWarnings(Consumer<Problem> problemConsumer) {
    if (problems.isEmpty() || computedResult == null) {
      return this;
    } else {
      if (problems.stream().allMatch(p -> p.severity.ordinal() <= Severity.WARNING.ordinal())) {
        problems.forEach(problemConsumer);
        return ResultOrProblems.of(computedResult);
      } else {
        return this;
      }
    }
  }

  /**
   * Removes any warnings or lower from a successful {@link ResultOrProblems} so that get() doesn't produce a warning.
   *
   * Similar to {@link #drainWarnings(java.util.function.Consumer) } except that if non error problems are
   * found then the callback used to get a problem that is then passed to the problemConsumer
   */
  public ResultOrProblems<T> drainWarnings(Consumer<Problem> problemConsumer,
      BiFunction<Severity, List<Problem>, Problem> callback) {
    if (problems.isEmpty() || computedResult == null) {
      return this;
    } else {
      if (problems.stream().allMatch(p -> p.severity.ordinal() <= Severity.WARNING.ordinal())) {
        Severity max = problems.getSeverity();
        problemConsumer.accept(callback.apply(max, problems.toList()));
        return ResultOrProblems.of(computedResult);
      } else {
        return this;
      }
    }
  }


  /**
   * Call the given function if there were any problems with this {@link ResultOrProblems}, returning whatever
   * the function returned.
   */
  public <U> Optional<U> ifProblems(Function<List<Problem>, U> function) {
    if (problems.isPresent()) {
      return Optional.ofNullable(function.apply(problems.toList()));
    } else {
      return Optional.empty();
    }
  }

  /**
   * @return true if result is set.
   */
  public boolean isPresent() {
    return computedResult != null;
  }

  /**
   * Call the given consumer function with the result, only if result is set
   */
  public void ifPresent(Consumer<T> function) {
    if (computedResult != null) {
      function.accept(computedResult);
    }
  }

  /**
   * Call the given consumer function with the result, only if result is set, else call the consumer
   */
  public void ifElse(Consumer<T> function, Consumer<List<Problem>> elseConsume) {
    if (computedResult != null) {
      function.accept(computedResult);
    } else {
      elseConsume.accept(problems.toList());
    }
  }


  /**
   * Call the given consumer function with the result, only if result is set, else call the consumer
   */
  public <U> U ifElseReturn(Function<T, U> function, Function<List<Problem>, U> elseConsume) {
    if (computedResult != null) {
      return function.apply(computedResult);
    } else {
      return elseConsume.apply(problems.toList());
    }
  }

  /**
   * Call the given consumer function with the result, only if result is set, else call the consumer
   */
  public <U> U ifElseReturn(Function<T, U> function, Consumer<List<Problem>> elseConsume, U elseReturn) {
    if (computedResult != null) {
      return function.apply(computedResult);
    } else {
      elseConsume.accept(problems.toList());
      return elseReturn;
    }
  }

  /**
   * @return true if this {@link ResultOrProblems} has any problems, regardless of severity.
   */
  public boolean hasProblems() {
    return problems.isPresent();
  }

  /**
   * @return true if this object's list of problems contain any that are of an equal or greater severity to
   * the given severity parameter.
   */
  public boolean hasProblems(Severity greaterThanEqualTo) {
    for (Problem problem : problems) {
      if (problem.getSeverity().ordinal() >= greaterThanEqualTo.ordinal()) {
        return true;
      }
    }
    return false;
  }

  /**
   * Filters the Problems based on the class of thing the problem affects,
   * e.g. find all the problems that relate to a parameter.
   * @param affects the type of thing affected by the Problem, e.g. Parameter.class, Step.class
   * @return A list of Problems that affect the given class
   */
  public List<Problem> filterProblems(Class<?> affects) {
    return Problem.filterAffected(problems.toList(), affects);
  }

  /**
   * like {@link #flatMap(BiFunction)}, but appends the problem lists together in a new {@link ResultOrProblems} object
   */
  public <U> ResultOrProblems<U> flatMap(Function<? super T, ResultOrProblems<U>> mapper) {
    return flatMap((t, p) -> {
      ResultOrProblems<U> mapped = mapper.apply(t);
      return ResultOrProblems.ofNullable(mapped.computedResult,
          ListUtils.concat(this.problems.toList(), mapped.problems.toList()));
    });
  }

  /**
   * Maps this {@link ResultOrProblems} to another, only if the result has been set.
   * It is up to the BiFunction to produce a new set of problems - normally you want to append problems, so use
   * the {@link Function} form of this method.
   */
  @SuppressWarnings("unchecked")
  public <U> ResultOrProblems<U> flatMap(BiFunction<T, List<Problem>, ResultOrProblems<U>> mapper) {
    ResultOrProblems<U> mapped = null;
    if (computedResult != null) {
      mapped = mapper.apply(computedResult, problems.toList());
    } else {
      return (ResultOrProblems<U>) this;
    }

    return mapped;
  }

  /**
   * Maps the result of this {@link ResultOrProblems} to another result, only if the result has been set.
   */
  public <U> ResultOrProblems<U> map(Function<T, U> mapper) {
    U mapped = null;
    if (computedResult != null) {
      mapped = mapper.apply(computedResult);
      if (mapped == null) {
        throw new NullPointerException("mapper must return a result, or use flatMap");
      }
    }

    return new ResultOrProblems<>(mapped, problems.toList());
  }

  /**
   * Maps the result of this {@link ResultOrProblems} to another result, only if the result has been set,
   * as well as supporting remapping problems one at a time regardless of a result.
   * @param problemMapper a function to convert the problem list.
   */
  public <U> ResultOrProblems<U> map(
      Function<T, U> mapper,
      Function<Problem, Problem> problemMapper) {

    U mapped = null;
    if (computedResult != null) {
      mapped = mapper.apply(computedResult);
      if (mapped == null) {
        throw new NullPointerException("mapper must return a result, or use flatMap");
      }
    }

    return new ResultOrProblems<>(mapped, problems.stream().map(problemMapper).collect(Collectors.toList()));
  }

  /**
   * Like {@link #map(Function)}, but will only map if this and all dependencies succeeded.  A failed result
   * includes problems from this and all dependencies.
   * @param <U> The mapped result type
   * @param mapper a mapping function for a successful result
   * @param dependencies other ResultOrProblems to check for failures before calling the mapping function
   * @return a successful result of type <U> or a failed result containing all problems.
   */
  public <U> ResultOrProblems<U> mapOrCombine(Function<T, U> mapper, ResultOrProblems<?>... dependencies) {
    List<ResultOrProblems<?>> all = ListUtils.prepend(this, dependencies);

    for (ResultOrProblems<?> dependency : all) {
      if (dependency.hasErrors()) {
        return ResultOrProblems.failed(all);
      }
    }

    return map(mapper);
  }

  /**
   * @return either the set result, or otherThing if result is not set.  otherThing can be null.
   */
  public T orElse(T otherThing) {
    if (this.computedResult == null) {
      return otherThing;
    } else {
      // TODO logIfWarningsIgnored();
      return computedResult;
    }
  }

  /**
   * @return either the set result, or otherThing if result is not set.  otherThing can be null.
   */
  public T orElse(Consumer<List<Problem>> consumeProblems, T elseReturn) {
    if (this.computedResult == null) {
      consumeProblems.accept(problems.toList());
      return elseReturn;
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }
  /**
   * @return either the set result, or otherThing if result is not set.  otherThing can be null.
   */
  public T orElseGet(Function<List<Problem>, T> resultFunction) {
    if (this.computedResult == null) {
      return resultFunction.apply(problems.toList());
    } else {
      logIfWarningsIgnored();
      return computedResult;
    }
  }

  /**
   * @return a new {@link ResultOrProblems} object that contains the existing list of problems as well as the given
   * list of problems.
   */
  public ResultOrProblems<T> withMoreProblems(Problem... moreProblems) {
    return withMoreProblems(Arrays.asList(moreProblems));
  }

  /**
   * Applies the given validation function to the computed result, and returns a
   * new ResultOrProblems with any additional problems that were found
   */
  public ResultOrProblems<T> withMoreProblems(Function<T, List<Problem>> validation) {
    if (computedResult == null) {
      return this;
    } else {
      return withMoreProblems(validation.apply(computedResult));
    }
  }

  /**
   * @return a new {@link ResultOrProblems} object that contains the existing list of problems as well as the given
   * list of problems.
   */
  public ResultOrProblems<T> withMoreProblems(Collection<Problem> moreProblems) {
    List<Problem> newProblems = Lists.newArrayList();
    newProblems.addAll(this.problems.toList());
    newProblems.addAll(moreProblems);
    return new ResultOrProblems<>(this.computedResult, newProblems);
  }

  /**
   * Mirror of {@link Optional#orElseThrow(Supplier)} - throws the given exception if there is no computed result.
   */
  public <X extends Throwable> T orElseThrow(Function<List<Problem>, ? extends X> exceptionSupplier) throws X {
    if (this.computedResult != null) {
      // TODO logIfWarningsIgnored();
      return computedResult;
    } else {
      throw exceptionSupplier.apply(problems.toList());
    }
  }

  /**
   * Mirror of {@link Optional#orElseThrow(Supplier)} - throws the given exception if there is no computed result.
   */
  public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {
    if (this.computedResult != null) {
      logIfWarningsIgnored();
      return computedResult;
    } else {
      throw exceptionSupplier.get();
    }
  }

  @Override
  public String toString() {
    if (this.computedResult != null) {
      if (problems.isEmpty()) {
        return String.format("Of(%s)", computedResult);
      } else {
        return String.format("Of(%s, %d problems)", computedResult, problems.toList().size());
      }
    } else {
      return String.format("Failed(%s)", problems);
    }
  }

  /**
   * Shortcut for {@code hasProblems(Severity.ERROR)}
   * @return true if there are any errors
   */
  public boolean hasErrors() {
    return hasProblems(Severity.ERROR);
  }

  @Override
  public boolean equals(Object obj) {
    if (!(obj instanceof ResultOrProblems)) {
      return false;
    }
    ResultOrProblems<?> rhs = (ResultOrProblems<?>) obj;
    return Objects.equals(this.computedResult, rhs.computedResult)
        &&
        Objects.equals(this.problems, rhs.problems);
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.computedResult, this.problems);
  }

  /**
   * @deprecated For new code, use the i18n Problem-based method below instead
   * */
  @Deprecated
  public ResultOrProblems<T> composeProblems(String message, Object... args) {
    if (hasProblems()) {
      return ResultOrProblems.ofNullable(
          this.computedResult,
          Collections.singletonList(Problem.composite(problems.toList(), message, args))
      );
    } else {
      return this;
    }
  }

  /**
   * If this result has problems, then this method will return a new ResultOrProblems with a single problem
   * with a new message that has this objects problems as children.

   * Acts as a conditional shortcut for Problem.composite(resultOr.getProblems()..) - a common pattern when
   * nesting problems with more context, e.g an expression failed that was part of a step, that was part of a pipeline,
   * etc.
   *
   */
  public ResultOrProblems<T> composeProblems(Problem parentProblem) {
    if (hasProblems()) {
      Severity severity = problems.getSeverity();
      return ResultOrProblems.ofNullable(
          this.computedResult,
          Collections.singletonList(parentProblem.withChildren(problems).withSeverity(severity))
      );
    } else {
      return this;
    }
  }

  /**
   * Variant of {@link #composeProblems(Problem)} that allows the problem to be constructed via a callback.  Can be
   * more clear or possibly required if a problem can not be constructed without children.
   * @param callback that will be invoked if there are problems on this {@link ResultOrProblems} object.
   * @return a new {@link ResultOrProblems} with a single problem that was created by `callback`, or the original
   * object if there are no problems on this {@link ResultOrProblems} object.
   */
  public ResultOrProblems<T> composeProblems(BiFunction<Severity, List<Problem>, Problem> callback) {
    if (hasProblems()) {
      Severity severity = problems.getSeverity();
      return ResultOrProblems.ofNullable(
          this.computedResult,
          Collections.singletonList(callback.apply(severity, getProblems()))
      );
    } else {
      return this;
    }
  }

  /**
   * Compose a failed result with a new type and a parent error message.  Will fail if this is not already a failure
   */
  public <U> ResultOrProblems<U> composeFailure(Problem parentProblem) {
    Severity severity = problems.getSeverity();
    return ResultOrProblems.failed(parentProblem.withChildren(problems).withSeverity(severity));
  }

  public ResultOrProblems<T> addProblemsTo(Collection<Problem> collection) {
    collection.addAll(this.problems.toList());
    return this;
  }

  /**
   * Returns the Problems found as a single Problem, rather than a List. This can
   * be useful for propagating errors via a RiskScapeException.
   * Use this API if:
   * - you're pretty sure you're only ever going to have a single problem in the list.
   * - Or, the code you're dealing with is so generic, there's not a more appropriate
   *   object/context to use (i.e. you don't really know what the underlying 'thing' is).
   * Otherwise, use the Problems.foundWith() API instead.
   */
  public Problem getAsSingleProblem() {
    return problems.toProblem().get();
  }
}
