/*
 * 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.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.collect.Iterators;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.engine.Identified;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.problem.ProblemPlaceholder;
import nz.org.riskscape.engine.util.Pair;

/**
 * Class used for describing errors and other issues with user defined input.  Problems are hierarchical, which
 * is used to 'target' where issues are found, e.g. a problem with your function that is in your model that is in your
 * project might have three levels, e.g. `There was a problem in your project... -> There was a problem with your
 * model... -> The function 'hazard-calculator' does not exist`
 *
 * This code is going through a 'transition' phase as it moves away from free form messages to something more structured
 * and linked to the i18n system.  Over time,  we should:
 * * purge all the code related to default messages
 * * use annotations to apply structure to the various args, e.g. an 'expected' and 'actual' argument
 *
 * See {@link Problems} for tips on constructing Problems.
 */
@RequiredArgsConstructor
@EqualsAndHashCode
public class Problem implements Problems {

  public static AffectedMetadata wrapAffectedObject(Object thing) {
    if (thing == null) {
      return null;
    }

    if (thing instanceof ProblemPlaceholder) {
      // ProblemPlaceholder is a placeholder object, so just unwrap its class/ID
      ProblemPlaceholder identified = (ProblemPlaceholder) thing;
      return new AffectedMetadata(null, identified.getWrappedClass(), identified.getId());
    }
    String name = thing instanceof Identified ? ((Identified) thing).getId() : "";

    // This is a fix for tests - without this, the type of list will prevent problems that accept a list
    // argument as its 'affected' item from matching unless they are the exact same implementation of a list, which is
    // never going to be important for the purposes of test equality.
    Class<?> reportedClass;
    if (thing instanceof List) {
      reportedClass = List.class;
    } else {
      reportedClass = thing.getClass();
    }

    return new AffectedMetadata(thing, reportedClass, name);
  }

  public enum Severity {
    INFO,
    WARNING,
    ERROR,
    FATAL
  }

  @Getter
  public final Severity severity;

  /**
   * Stores the unformatted message string that will be used as the default
   * message (i.e. if the i18n lookup fails to find a better message).
   * Note that this is in String format (rather than MessageFormat format).
   */
  private final String defaultMessageFormat;

  @Getter
  private final Object[] arguments;

  @Getter
  private final Throwable exception;

  @Getter
  private final ProblemCode code;

  /**
   * Basic metadata about what this specific problem affects, e.g. is it a problem
   * with a Bookmark, or Parameter, or Expression, etc.
   */
  private final AffectedMetadata affected;

  private final Problems children;

  @Data
  static class AffectedMetadata {
    @Getter
    private final Object underlyingObject;
    @Getter
    private final Class<?> underlyingClass;
    @Getter
    private final String name;

    @Override
    public String toString() {
      if (underlyingObject == null) {
        return underlyingClass.toString() + ":" + name;
      } else {
        return underlyingObject.toString();
      }
    }
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem info(String message, Object... args) {
    return new Problem(Severity.INFO, message, args, null);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem warning(String message, Object... args) {
    return new Problem(Severity.WARNING, message, args, null);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem warning(Throwable t, String message, Object... args) {
    return new Problem(Severity.WARNING, message, args, t);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem error(String message, Object... args) {
    return new Problem(Severity.ERROR, message, args, null);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem error(Throwable t, String message, Object... args) {
    return new Problem(Severity.ERROR, message, args, t);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public static Problem fatal(Throwable t, String message, Object... args) {
    return new Problem(Severity.FATAL, message, args, t);
  }

  /**
   * Builds a {@link Problem} that contains the child {@link Problem}s.
   *
   * The level of the the returned {@link Problem} is the most severe from those of the children.
   *
   * @param children the problems the new Problem should wrap
   * @deprecated use the ProblemCode-based constructor instead
   */
  @Deprecated
  public static Problem composite(List<Problem> children, String message, Object... args) {
    return composite(max(children), children, message, args);
  }

  /**
   * Builds a {@link Problem} that contains the child {@link Problem}s, forcing a particular severity for the created
   * parent.
   *
   * @param children the problems the new Problem should wrap
   * @deprecated use the ProblemCode-based constructor instead
   */
  @Deprecated
  public static Problem composite(Severity severity, List<Problem> children, String message, Object... args) {
    if (children.isEmpty()) {
      throw new IllegalArgumentException("Can not create a parent problem without children");
    }

    return new Problem(children, severity, message, args);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public Problem(Severity severity, String message) {
    this(severity, "%s", new Object[] {message}, null);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public Problem(Severity severity, String message, Object[] args, Throwable t) {
    this(severity, message, args, t, StandardCodes.NONE, null, Problems.NONE);
  }

  /** @deprecated use the ProblemCode-based constructor instead */
  @Deprecated
  public Problem(List<Problem> children, Severity severity, String message, Object[] args) {
    this(severity, message, args, null, StandardCodes.NONE, null, Problems.from(children));
  }

  /**
   * Create an problem with {@link Severity#ERROR} with a particular
   * {@link ProblemCode} and message arguments.
   */
  public static Problem error(ProblemCode code, Object... args) {
    return new Problem(Severity.ERROR, code, args);
  }


  /**
   * Same as {@link #error(ProblemCode, Object...)}, but where you need to use a
   * different severity, e.g. INFO, WARNING, etc.
   */
  public Problem(Severity severity, ProblemCode code, Object... args) {
    this(severity, "", args, null, code, null, Problems.NONE);
  }

  public Problem(List<Problem> children, ProblemCode code, Object... args) {
    this(max(children), "", args, null, code, null, Problems.from(children));
  }

  public static Severity max(Collection<? extends Problem> children) {
    return children.stream().map(p -> p.severity).max((s1, s2) -> s1.compareTo(s2)).orElse(Severity.INFO);
  }

  public static boolean hasErrors(Collection<? extends Problem> problems) {
    return max(problems).ordinal() >= Severity.ERROR.ordinal();
  }

  public static <T extends Throwable> void throwIfErrors(List<Problem> problems, Function<List<Problem>, T> supplier)
      throws T {
    if (hasErrors(problems)) {
      throw supplier.apply(problems);
    }
  }

  public Problem withChildren(Problems... moreChildren) {
    List<Problem> all = new ArrayList<>();
    for (Problems more : moreChildren) {
      all.addAll(more.toList());
    }
    return withChildren(all);
  }

  /**
   * Copy this Problem but add in more children.
   */
  public Problem withChildren(List<? extends Problem> moreChildren) {
    List<Problem> combinedProblems = new ArrayList<>(children.toList());
    combinedProblems.addAll(moreChildren);
    return new Problem(severity, defaultMessageFormat, arguments, exception,
        code, affected, Problems.from(combinedProblems));
  }

  /**
   * Copy this Problem but replace the children.
   */
  public Problem setChildren(List<Problem> newChildren) {
    return new Problem(severity, defaultMessageFormat, arguments, exception,
        code, affected, Problems.from(newChildren));
  }

  /**
   * Copy this Problem but with a new severity
   */
  public Problem withSeverity(Severity sev) {
    return new Problem(sev, defaultMessageFormat, arguments, exception,
        code, affected, children);
  }

  /**
   * Copy this Problem but with a new exception
   */
  public Problem withException(Throwable t) {
    return new Problem(severity, defaultMessageFormat, arguments, t,
        code, affected, children);
  }

  /**
   * Copy this Problem but add details of the specific thing that the problem
   * affects. E.g. the specific Parameter or pipeline Step affected, etc. Note
   * that there are several variants as in some cases we may know the class and
   * name but not have an object, whereas other cases may have an object but no
   * name.
   */
  public Problem affecting(Class<?> clazz, String name) {
    return affecting(new AffectedMetadata(null, clazz, name));
  }

  public Problem affecting(Object thing) {
    return affecting(wrapAffectedObject(thing));
  }

  private Problem affecting(AffectedMetadata affects) {
    return new Problem(severity, defaultMessageFormat, arguments, exception, code, affects, children);
  }

  public boolean isError() {
    return severity.ordinal() >= Severity.ERROR.ordinal();
  }

  public boolean isFatal() {
    return this.severity == Severity.FATAL;
  }

  public boolean hasChildren() {
    return !children.isEmpty();
  }

  public String getMessage() {
    return getDefaultMessage();
  }

  public String getDefaultMessage() {
    String message;

    /* if there's no default message, just display what details we can about the Problem */
    if (defaultMessageFormat.isEmpty()) {
      message = String.format("%s.%s: args=%s", ProblemCode.class.getSimpleName(),
          code.name(), Arrays.toString(arguments));
    } else {
      message = String.format(defaultMessageFormat, arguments);
    }
    return message;
  }
  /**
   * @return an Optional wrapping the affected 'thing' associated with this Problem.
   * i.e. this represents the underlying thing that generated the Problem
   */
  private Optional<AffectedMetadata> getAffected() {
    return Optional.ofNullable(affected);
  }

  /**
   * @return the name of the thing affected by this Problem (or an empty String if
   * there is no such context associated with it)
   */
  public String getAffectedName() {
    return getAffected().map(AffectedMetadata::getName).orElse("");
  }

  /**
   * @return the thing affected by this Problem, e.g. the Parameter object.
   * Returns Optional.empty() if there is no specific object affected.
   */
  public Optional<Object> getAffectedObject() {
    return getAffected()
        .map(c -> Optional.ofNullable(c.getUnderlyingObject()))
        .orElse(Optional.empty());
  }

  /**
   * @return the class of thing affected by this Problem, e.g. Parameter.class.
   * Returns Object.class if this is unknown.
   */
  public Class<?> getAffectedClass() {
    if (getAffected().isPresent()) {
      return getAffected().get().getUnderlyingClass();
    } else {
      return Object.class;
    }
  }

  /**
   * @return true if the Problem affects the given class or superclass
   */
  public boolean affects(Class<?> superclass) {
    return getAffected().map(c -> superclass.isAssignableFrom(c.getUnderlyingClass())).orElse(false);
  }

  /**
   * With Problems, there is usually something this is *most* affected by the
   * Problem, e.g. a Parameter or a File or a Bookmark. This checks if this
   * Problem affects the class given, and if so, returns that thing. E.g. 'I know
   * this Problem affects a File, gimme the File'.
   */
  @SuppressWarnings("unchecked")
  public <T> Optional<T> getAffected(Class<T> classOfThing) {
    if (this.affects(classOfThing)) {
      // we still can't guarantee that an object is present, but if it is
      // then it should match the given class
      return (Optional<T>) getAffectedObject();
    }
    return Optional.empty();
  }

  private static void filterAffected(Collection<? extends Problem> problems,
      Class<?> contextClass, List<Problem> filteredResult) {

    for (Problem problem : problems) {
      if (problem.affects(contextClass)) {
        filteredResult.add(problem);
      }
      /* recursively scan the children (and their children) for a match as well */
      if (problem.hasChildren()) {
        filterAffected(problem.getChildren(), contextClass, filteredResult);
      }
    }
  }

  /**
   * Filters the list of Problems (and their children) based on the affected
   * class, e.g. find any/all problems that affect a Parameter, Step, etc. A
   * flattened list is returned, i.e. it may be a mix of parents and children,
   * regardless of where they occur in the problem hierarchy.
   *
   * @return a list of Problems that affect the given class. Or an empty list, if
   * no Problems affect the given class.
   */
  public static List<Problem> filterAffected(Collection<? extends Problem> problems,
      Class<?> contextClass) {
    List<Problem> filteredResult = new ArrayList<>();
    filterAffected(problems, contextClass, filteredResult);
    return filteredResult;
  }

  /**
   * @return a string that gives some idea of the structure of this problem and its children, but without requiring
   * any i18n resources to give actual user-facing error messages
   */
  public static String debugString(Problem origin) {
    StringBuilder builder = new StringBuilder();
    LinkedList<Pair<String, Problem>> stack = new LinkedList<>();
    stack.add(Pair.of("", origin));
    while (!stack.isEmpty()) {
      Pair<String, Problem> current = stack.removeFirst();
      Problem problem = current.getRight();

      builder.append(current.getLeft()).append(problem.toString()).append(OsUtils.LINE_SEPARATOR);
      if (problem.hasChildren()) {
        String newIndent = current.getLeft() + "  ";
        stack.addAll(problem.getChildren().stream().map(p -> Pair.of(newIndent, p)).collect(Collectors.toList()));
      }
    }

    return builder.toString();
  }

  /**
   * Variation of {@link #debugString(Problem)} that dumps out a list of problems
   */
  public static String debugString(List<Problem> problems) {
    return problems.stream().map(p -> debugString(p)).collect(Collectors.joining(OsUtils.LINE_SEPARATOR));
  }

  public List<Problem> getChildren() {
    return children.toList();
  }

  @Override
  public String toString() {
    StringBuilder builder = new StringBuilder();

    builder.append("Problem(").append(severity).append(": ").append(code.name());

    if (arguments.length > 0) {
      builder.append("[");
      boolean first = true;
      for (Object arg : arguments) {
        if (!first) {
          builder.append(", ");
        }
        if (arg != null) {
          builder.append("'").append(arg.toString()).append("'");
        } else {
          builder.append("null");
        }
        first = false;
      }
      builder.append("]");
    }

    if (affected != null) {
      builder.append(" affected=").append(affected.toString());
    }

    if (exception != null) {
      builder.append(" exception=").append(exception);
    }

    if (children.isPresent()) {
      builder.append("children=").append(children.toString());
    }


    builder.append(")");
    return builder.toString();
  }

  /**
   * Finds any exception info related to this problem, including exceptions that
   * may have been caught by child/nested Problems
   */
  public Throwable findAnyException() {
    if (exception != null) {
      return exception;
    }
    // check our children for any exceptions and propagate those back up
    for (Problem child : children) {
      Throwable childCause = child.findAnyException();
      if (childCause != null) {
        return childCause;
      }
    }
    return null;
  }

  @Override
  public Iterator<Problem> iterator() {
    return Iterators.singletonIterator(this);
  }

  @Override
  public Optional<Problem> toProblem() {
    return Optional.of(this);
  }

  @Override
  public Stream<Problem> stream() {
    return Stream.of(this);
  }

  @Override
  public List<Problem> toList() {
    return List.of(this);
  }

  @Override
  public boolean isEmpty() {
    return false;
  }

  @Override
  public boolean isPresent() {
    return true;
  }
}
