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

import static nz.org.riskscape.wizard.bind.CompositeBinder.*;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Range;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.ReflectionUtils;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.i18n.MessageSource;
import nz.org.riskscape.wizard.bind.CompositeBinder;
import nz.org.riskscape.wizard.bld.IncrementalBuildState;
import nz.org.riskscape.wizard.bld.loc.AtLastChange;
import nz.org.riskscape.wizard.bld.loc.ChangeLocation;
import nz.org.riskscape.wizard.survey2.Choices;
import nz.org.riskscape.wizard.survey2.Phase;

/**
 * A Question to ask the user within a {@link Survey}.
 *
 * Questions can have a predicate test (i.e. {@link #askWhen(Predicate)}
 * associated with them to control whether or not they should be asked.
 * Questions may be optional or required, and may accept multiple Answers.
 * Questions can have dependencies (i.e. {@link #dependsOn(String)}, so they
 * don't get asked until after an earlier Question has been answered.
 */
@Slf4j
@Getter
@EqualsAndHashCode(doNotUseGetters = true, of = {"name", "questionSet"})
public class Question {
  /**
   * @return true if the given type is for a "composite" question, i.e. a question that is
   *         actually split into several composite sub-parts
   */
  public static boolean isComposite(Class<?> clazz) {
    return clazz.getDeclaredAnnotation(Composite.class) != null;
  }

  /**
   * @return a list of the sub-parts to a composite question, or an empty list if given class is is not composite or has
   * no composite fields
   */
  public static List<Question> getCompositeSubparts(Question question, Class<?> clazz) {
    if (isComposite(clazz)) {
      return ReflectionUtils.getAnnotatedFields(clazz, CompositeField.class).stream()
          .map(field -> fieldifyQuestion(question, field))
          .collect(Collectors.toList());
    } else {
      return Collections.emptyList();
    }
  }

  /**
   * @return a cleaned up name that is OK to use as a label for this question
   */
  public static String formatName(String name) {
    String onlyAlphas = name.replaceAll("[^a-zA-Z0-9]", " ");
    return onlyAlphas.substring(0, 1).toUpperCase() + onlyAlphas.substring(1);
  }

  /**
   * The parameter type assigned to all hidden questions.  Needs to be a real value (and not void) as hidden questions
   * are required and so a response must be recoreded.
   *
   */
  public static final class Hidden {
    private Hidden() {}

    @Override
    public String toString() {
      return "<Hidden>";
    }
  }

  /**
   * The only instance of the hidden value you'll ever need.  Should be stored as the bound value in a Response object,
   * with an empty string or empty list as the user's original value (or some other non null and empty value)
   */
  public static final Hidden HIDDEN_VALUE = new Hidden();


  /**
   * A functional interface for returning translated strings for a question.
   */
  @FunctionalInterface
  public interface I18nLookupFunction {
    /**
     * Lookup a string for a question.  No {@link MessageSource} argument is
     * given - it is expected that the function will 'close over' one to use as a source of translations.
     * @param question the question that needs a translation
     * @param suffix some discriminator that identifies the kind of text we're looking up,e.g. `title` or `description`
     * - it is expected that this gets used to build a key when doing an i18n, but it could also be used in a case
     * statement
     * (I guess?)
     * @param locale the locale that the user is in
     * @return a translated string, or null if nothing is present.
     */
    Optional<String> apply(Question question, String suffix, Locale locale);
  }

  public static final I18nLookupFunction DEFAULT_I18N_LOOKUP = (q, suffix, locale) -> {
    String key = "question." + q.getQuestionSet().getName() + "." + q.getName() + "." + suffix;
    String i18nMsg = q.getMessageSource().getMessage(key, new Object[0], locale);
    if (i18nMsg == null) {
      log.info("No i18n message found for {}", key);
    }
    return Optional.ofNullable(i18nMsg);
  };

  public static final Range<Integer> ONE_REQUIRED = Range.singleton(1);
  public static final Range<Integer> ONE_AT_LEAST = Range.atLeast(1);
  public static final Range<Integer> OPTIONAL_ONE = Range.singleton(0).span(ONE_REQUIRED);
  public static final Range<Integer> OPTIONAL_MANY = Range.atLeast(0);

  /**
   * Assigned to annotations without values, so that we don't have to deal with null
   */
  public static final String NO_VALUE = "";

  @NonNull
  private final String name;

  @NonNull
  private final Class<?> parameterType;
  private final Question dependsOn;
  @NonNull
  private final Predicate<IncrementalBuildState> shouldBeAsked;
  // whether Question is required/optional and how many Responses are allowed
  @NonNull
  private final Range<Integer> arity;

  @NonNull
  private final ImmutableMap<String, String> annotations;

  private final QuestionSet questionSet;

  private final I18nLookupFunction i18nLookup;

  private final Function<IncrementalBuildState, List<Choice>> choiceFunction;

  /**
   * @return a ChangeLocation that is associated with this question.  The {@link ChangeLocation} provides an indication
   * to UIs and to Survey code of where the change triggered by a response to this question should be applied.  It
   * defaults to 'at the last change', which is what 90% of questions will want to do when incrementally building.
   */
  @NonNull
  private final ChangeLocation changeLocation;

  private final AskAs<?> askAs;

  public Question(String name, Class<?> parameterType) {
    this.name = name;
    this.parameterType = parameterType;
    this.annotations = ImmutableMap.of();
    this.arity = ONE_REQUIRED;
    this.dependsOn = null;
    this.shouldBeAsked = as -> true;
    this.questionSet = QuestionSet.UNASSIGNED;
    this.changeLocation = AtLastChange.INSTANCE;
    this.i18nLookup = DEFAULT_I18N_LOOKUP;
    this.choiceFunction = null;
    this.askAs = null;
  }

  // mutable class of question fields used for cloning
  private static final class MutableFields {

    MutableFields(Question source) {
      this.name = source.name;
      this.parameterType = source.parameterType;
      this.dependsOn = source.dependsOn;
      this.arity = source.arity;
      this.annotations = new HashMap<>(source.annotations);
      this.questionSet = source.questionSet;
      this.shouldBeAsked = source.shouldBeAsked;
      this.changeLocation = source.changeLocation;
      this.i18nLookup = source.i18nLookup;
      this.choiceFunction = source.choiceFunction;
      this.askAs = source.askAs;
    }

    String message;
    String title;
    String name;
    Class<?> parameterType;
    Question dependsOn;
    Range<Integer> arity;
    Map<String, String> annotations;
    QuestionSet questionSet;
    Predicate<IncrementalBuildState> shouldBeAsked;
    ChangeLocation changeLocation;
    I18nLookupFunction i18nLookup;
    Function<IncrementalBuildState, List<Choice>> choiceFunction;
    AskAs<?> askAs;
  }

  private Question(MutableFields fields) {
    this.name = fields.name;
    this.parameterType = fields.parameterType;
    this.dependsOn = fields.dependsOn;
    this.shouldBeAsked = fields.shouldBeAsked;
    this.arity = fields.arity;
    this.annotations = ImmutableMap.copyOf(fields.annotations);
    this.questionSet = fields.questionSet;
    this.changeLocation = fields.changeLocation;
    this.i18nLookup = fields.i18nLookup;
    this.choiceFunction = fields.choiceFunction;
    this.askAs = fields.askAs;
  }

  private boolean shouldBeAsked(IncrementalBuildState buildState) {
    return shouldBeAsked.test(buildState);
  }

  /**
   * Checks the dependent questions have already been asked *and* the question is applicable.
   */
  public boolean readyToBeAsked(IncrementalBuildState buildState) {
    return (
      dependsOn == null || buildState.isResponseGiven(getQuestionSet().getId(), dependsOn.getName()))
      &&
      shouldBeAsked(buildState);
  }

  /**
   * @return true if this question is dependent on (i.e. follows) the given question.
   */
  public boolean isFollowing(Question question) {
    return dependsOn == null && question == IncrementalBuildState.EMPTY_QUESTION
        || (dependsOn != null && dependsOn.equals(question));
  }

  private Question withArity(Range<Integer> arityRequired) {
    return clone(fields -> fields.arity = arityRequired);
  }

  public Question atLeastOne() {
    return withArity(ONE_AT_LEAST);
  }

  public Question requiredOne() {
    return withArity(ONE_REQUIRED);
  }

  public Question optionalOne() {
    return withArity(OPTIONAL_ONE);
  }

  public Question optionalMany() {
    return withArity(OPTIONAL_MANY);
  }

  /**
   * Constructs a clone of this question, but makes it a hidden question.  A hidden question is one that doesn't require
   * any input from the user, but is needed for some automatic changes to take place.  For example, a hidden question
   * might be at the end of a set of questions to plop a name on the last pipeline step to give it a well-known name for
   * later questions to locate it simply.  A hidden question should be processed automatically as soon as the previous
   * question is answered.  Note that hidden questions won't be supported by the original Survey APIs and is destined
   * for use with the newer Survey2 APIs
   * @return a new question with the arity set to one and the type as {@link Hidden}
   */
  public Question hidden() {
    return withArity(ONE_REQUIRED).withType(Hidden.class);
  }

  /**
   * @return a copy of this {@link Question} but with the given tags replacing whatever was there before.
   * @param newAnnotationStrings a list of annotations to parse, each string can be either a tag (which will be
   * assigned NO_VALUE) or a `tag=value` string.
   */
  public Question parseAnnotations(String... newAnnotationStrings) {
    Map<String, String> parsedAnnotations = Arrays.asList(newAnnotationStrings).stream()
      // drop any garbage
      .map(str -> str.trim())
      .filter(str -> !str.isEmpty())
      // should be specified as key=value
      .map(str -> Arrays.asList(str.split("=")))
      // normalize everything to a 2 element array, empty string when no value specified
      .map(list -> list.size() < 2 ? Arrays.asList(list.get(0), NO_VALUE) : list)
      .collect(Collectors.toMap(list -> list.get(0).trim(), list -> list.get(1).trim()));

    return clone(fields -> fields.annotations = parsedAnnotations);
  }

  /**
   * @return a copy of this {@link Question} but with the given tags replacing whatever was there before
   */
  public Question withAnnotations(String k1, String v1) {
    return clone(fields -> fields.annotations = ImmutableMap.of(k1, v1));
  }

  /**
   * @return a copy of this {@link Question} but with the given tags replacing whatever was there before
   */
  public Question withAnnotations(String k1, String v1, String k2, String v2) {
    return clone(fields -> fields.annotations = ImmutableMap.of(k1, v1, k2, v2));
  }

  /**
   * @return a copy of this {@link Question} but with the given tags replacing whatever was there before
   */
  public Question withAnnotations(String k1, String v1, String k2, String v2, String k3, String v3) {
    return clone(fields -> fields.annotations = ImmutableMap.of(k1, v1, k2, v2, k3, v3));
  }

  /**
   * @return true if there is a tag attached to this {@link Question} that matches the given string
   */
  public boolean hasAnnotation(String tag) {
    return annotations.keySet().contains(tag);
  }

  /**
   * @return true if there is a tag attached to this {@link Question} that matches the tag and it has
   *         contains value
   */
  public boolean hasAnnotationWithValue(String tag, String value) {
    return getAnnotation(tag)
        .map(found -> found.equals(value))
        .orElse(false);
  }

  public Optional<String> getAnnotation(String tag) {
    return Optional.ofNullable(annotations.get(tag));
  }

  /**
   * Make a new Question that's dependent on the given questionName being asked
   * first. Note that these dependencies are only for Questions within the *same*
   * QuestionSet.
   */
  public Question dependsOn(Question newDependsOn) {
    return clone(fields -> fields.dependsOn = newDependsOn);
  }

  /**
   * Clone this question, but with a new parameter type field.
   */
  public Question withType(Class<?> newParameterType) {
    return clone(fields -> fields.parameterType = newParameterType);
  }

  /**
   * Adds a predicate condition that should be met before this question is asked.
   */
  public Question askWhen(Predicate<IncrementalBuildState> conditionMet) {
    return clone(fields -> fields.shouldBeAsked = conditionMet);
  }

  /**
   * @return true if this question wants at most one answer
   */
  public boolean isSingleValueQuestion() {
    return !arity.contains(2);
  }

  /**
   * @return true if at least one response is required for this question
   */
  public boolean isRequired() {
    return !arity.contains(0);
  }

  /**
   * The opposite (negation) of isRequired
   * @return true if this question is not hidden but can be skipped
   */
  public boolean isOptional() {
    return !isRequired();
  }

  /**
   * @return true if this question should be hidden from the user.  See {@link #hidden()}
   */
  public boolean isHidden() {
    return parameterType.equals(Hidden.class);
  }
  /**
   * Check if the question is really required. A question if required if it should be asked, and also
   * requires an answer.
   *
   * @return true if question requires a response
   */
  public boolean isRequired(IncrementalBuildState buildState) {
    return isRequired() && shouldBeAsked(buildState);
  }

  /**
   * sanity checking code to prevent an invalid answer being recorded - the errors
   * are not meant for human consumption but we might want to adapt them later
   */
  public void checkValidity(List<Object> values) {
    if (!arity.contains(values.size())) {
      throw new IllegalArgumentException("wrong arity - " + values.size() + " not within " + arity);
    }

    for (Object value : values) {
      if (!parameterType.isInstance(value)) {
        throw new IllegalArgumentException("wrong type (not a " + parameterType + ") - " + value);
      }
    }
  }

  /**
   * Explosive method for API users to fail fast if when they do a single value thing on a multi value question
   */
  public void requireSingleValueQuestion() {
    if (!isSingleValueQuestion()) {
      throw new IllegalArgumentException("question is not a single answer question " + name);
    }
  }

  /**
   * Returns a unique ID for the Question (which is dependent on the {@link QuestionSet} it belongs to.
   */
  public String getId() {
    return String.format("%s.%s", getQuestionSet().getId(), name);
  }

  /**
   * Returns a Parameter representing this question (so that saved wizard answers can be overridden from the CLI).
   */
  public Parameter toParameter() {
    int min = isRequired() ? 1 : 0;
    int max = isSingleValueQuestion() ? 1 : Integer.MAX_VALUE;
    return Parameter.range(getId(), parameterType, min, max);
  }

  @Override
  public String toString() {
    return String.format("Question[name=%s, type=%s, arity=%s, dependsOn=%s]", name, parameterType.getSimpleName(),
        arity, dependsOn);
  }

  private Question clone(Consumer<MutableFields> mutator) {
    MutableFields fields = new MutableFields(this);
    mutator.accept(fields);
    return new Question(fields);
  }

  /**
   * @return the QuestionSet this question belongs to
   * @throws IllegalStateException if this question is unassigned
   */
  public QuestionSet getQuestionSet() {
    if (isUnassigned()) {
      throw new IllegalStateException("question " + name + " is unassigned");
    }
    return questionSet;
  }

  /**
   * @return the phase this question is connected to (via its QuestionSet)
   */
  public Phase getPhase() {
    return getQuestionSet().getPhase();
  }

  /**
   * @return true if the question does not yet belong to a {@link QuestionSet}.  A question can assigned to a set by
   * calling the inSet clone method.
   */
  public boolean isUnassigned() {
    return questionSet == QuestionSet.UNASSIGNED;
  }

  /**
   * @return a clone of this Question but with it assigned to the given question set
   */
  public Question inSet(QuestionSet newQuestionSet) {
    return clone(fields -> fields.questionSet = newQuestionSet);
  }

  public Question withName(String newName) {
    return clone(fields -> fields.name = newName);
  }

  public Question withI18nLookup(I18nLookupFunction newLookup) {
    return clone(fields -> fields.i18nLookup = newLookup);
  }

  public Question withI18nLookup(BiFunction<String, Locale, String> newLookup) {
    return clone(fields -> fields.i18nLookup = (q, s, l) -> Optional.ofNullable(newLookup.apply(s, l)));
  }

  public Question atLocation(ChangeLocation newChangeLocation) {
    return clone(fields -> fields.changeLocation = newChangeLocation);
  }

  public Optional<String> getTitle(Locale locale) {
    return getMessage("title", locale);
  }

  public Optional<String> getDescription(Locale locale) {
    return getMessage("description", locale);
  }

  /**
   * Returns of concise bit of text that summarizes this question. This is really closer to title, and title should
   * be 'prompt', but to avoid churn, this gets a the new name instead of renaming all the things
   */
  public Optional<String> getSummary(Locale locale) {
    return getMessage("summary", locale);
  }

  public Optional<String> getMessage(String suffix, Locale locale) {
    return i18nLookup.apply(this, suffix, locale);
  }

  public MessageSource getMessageSource() {
    return getSurvey().getMessageSource();
  }

  public Survey getSurvey() {
    return getQuestionSet().getSurvey();
  }

  public Question withChoices(Function<IncrementalBuildState, List<Choice>> newFunction) {
    return clone(fields -> fields.choiceFunction = newFunction);
  }

  /**
   * @return A list of Choices that are acceptable, predefined answers for this question
   */
  public List<Choice> getChoices(IncrementalBuildState buildState) {
    if (choiceFunction != null) {
      return choiceFunction.apply(buildState);
    } else {
      return Choices.from(this, buildState);
    }
  }

  /**
   * @return true if this is a "composite" question, i.e. a question that is
   *         actually split into several composite sub-parts
   */
  public boolean isComposite() {
    return isComposite(getParameterType());
  }

  /**
   * @return a list of the sub-parts to a composite question
   */
  public List<Question> getCompositeSubparts() {
    return getCompositeSubparts(this, getParameterType());
  }

  public Optional<Question> getDependsOn() {
    return Optional.ofNullable(dependsOn);
  }

  /**
   * @return true if this question can be converted to a differently asked question for a customized UI, while
   * recording the result  as some other type.
   */
  public boolean isAskAs() {
    return this.askAs != null;
  }

  public <T> Question withAskAs(Class<T> newAskAsType, Function<T, String> newAskAsFunction) {
    return withAskAs(new AskAs<>(newAskAsType, newAskAsFunction));
  }

  public Question withAskAs(AskAs<?> newAskAs) {
    return clone(fields -> fields.askAs = newAskAs);
  }

  public Optional<AskAs> getAskAs() {
    return Optional.ofNullable(askAs);
  }

  public Question getAskedAs() {
    if (isAskAs()) {
      return withType(askAs.getType()).withAskAs(null);
    } else {
      return this;
    }
  }

  /**
   * @return the composite sub-question that corresponds to the given fieldName
   */
  public Optional<Question> getSubQuestion(String fieldName) {
    return getCompositeSubparts().stream()
        .filter(q -> CompositeBinder.fieldName(q).equals(fieldName))
        .findAny();
  }
}
