/*
 * 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 java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

import com.google.common.collect.Lists;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ResultOrProblems;

/**
 * Records an answer to a question that has been bound to a type according to {@link Question#getParameterType()}
 *
 * Note that an Answer can simply indicate that a Question was presented to the user, even if the
 * user choose to skip it and not give an actual response.
 */
@EqualsAndHashCode
public class Answer {

  /**
   * Records a user's response to a question. An Answer may contain multiple Responses,
   * depending on the {@link Question}'s arity, or it may have zero Responses if it was skipped.
   */
  @Data
  public static final class Response {

    /**
     * The result of converting user input in to an object that matches the type declared by
     * {@link Question#getParameterType()}
     */
    private final Object boundValue;

    /**
     * The input from the user that was used to create {@link #boundValue}
     */
    private final Object originalInput;
  }

  /**
   * Convenience method for binding a single input value for the given question in to a single {@link Response}
   */
  public static ResultOrProblems<Response> bind(BindingContext context, Question chosen, Object input) {
    return context.bind(input, chosen.getParameterType()).map(bound -> new Response(bound, input));
  }

  /**
   * Convenience method for binding a list of responses in to an answer, return a failed result if any of the responses
   * failed to bind.
   */
  public static ResultOrProblems<Answer> bindAll(BindingContext context, Question question, List<?> responses) {

    List<Problem> problems = new LinkedList<>();
    List<Response> successfulResponses = new ArrayList<>(responses.size());

    for (Object response : responses) {
      ResultOrProblems<Response> boundResponse = bind(context, question, response);

      if (boundResponse.isPresent()) {
        successfulResponses.add(boundResponse.drainWarnings(p -> problems.add(p)).get());
      } else {
        boundResponse.addProblemsTo(problems);
      }
    }

    if (Problem.hasErrors(problems)) {
      return ResultOrProblems.failed(problems);
    } else {
      return ResultOrProblems.of(new Answer(question, successfulResponses), problems);
    }
  }

  /**
   * Useful testing constructor for creating answers for tests where the bound type is a string as well.
   */
  public static Answer strings(Question answered, String... valuesAndResponses) {
    return new Answer(
        answered,
        Arrays.asList(valuesAndResponses).stream()
          .map(str -> new Response(str, str))
          .collect(Collectors.toList())
      );
  }

  /**
   * Useful testing constructor for assembling a single response answer
   */
  public static Answer single(Question answered, Object boundValue, Object response) {
    return new Answer(answered, Collections.singletonList(new Response(boundValue, response)));
  }

  /**
   * @return an Answer that is suitable for the given hidden question
   */
  public static Answer hidden(Question answered) {
    if (!answered.isHidden()) {
      throw new IllegalArgumentException("attempted to create a hidden answer from a question that's not hidden - "
          + answered);
    }
    return single(answered, Question.HIDDEN_VALUE, Collections.EMPTY_LIST);
  }

  /**
   * @return an Answer that represents the user skipping the given question
   */
  public static Answer skip(Question notAnswered) {
    return new Answer(notAnswered, Collections.emptyList());
  }

  public Answer(Question answered, Response... responses) {
    this(answered, Arrays.asList(responses));
  }

  public Answer(
      @NonNull Question answered,
      @NonNull List<Response> values
  ) {
    this.question = answered;
    this.responses = values;

    answered.checkValidity(getValues());
  }

  /**
   * The question that was answered
   */
  @Getter
  private final Question question;

  /**
   * A list of the user-given inputs paired with their bound values
   */
  private final List<Response> responses;

  public List<Object> getValues() {
    return Lists.transform(responses, resp -> resp.getBoundValue());
  }

  /**
   * Returns the bound value(s) for this answer as the given class.
   */
  public <T> List<T> getValuesAs(Class<T> asClass) {

    if (!asClass.isAssignableFrom(question.getParameterType())) {
      throw new IllegalArgumentException("given class " + asClass + " does not match question " + question);
    }

    List<T> values = new ArrayList<>(responses.size());
    responses.forEach((resp) -> {
      Object boundValue = resp.getBoundValue();
      if (asClass.isAssignableFrom(boundValue.getClass())) {
        values.add(asClass.cast(boundValue));
      } else {
        throw new AssertionError("some how an invariant response type has crept in - " + boundValue.getClass());
      }
    });
    return values;
  }

  /**
   * @return the bound value for the answer as the given class
   */
  public <T> T getValueAs(Class<T> asClass) {
    // fail fast if this isn't a single response question
    question.requireSingleValueQuestion();
    List<T> values = getValuesAs(asClass);
    return values.size() > 0 ? values.get(0) : null;
  }

  /**
   * @return the {@link Response} object that stores both the user supplied value *and* the bound value for this answer,
   * or null if no response was recorded.
   * @throws IllegalArgumentException if the question answered doesn't expect a single response
   */
  public Response getResponse() {
    question.requireSingleValueQuestion();
    if (responses.isEmpty()) {
      return null;
    } else {
      return responses.get(0);
    }
  }

  /**
   * @return a copy of the complete list of {@link Response}s for this answser
   */
  public List<Response> getResponses() {
    return new ArrayList<>(responses);
  }

  /**
   * @return a copy of the complete list of inputs the user gave as part of this answer
   */
  public List<Object> getOriginalResponses() {
    return responses.stream().map(Response::getOriginalInput).collect(Collectors.toList());
  }

  /**
   * Returns true if this answer was given for the given question and question set
   */
  public boolean isSameQuestion(Question rhsQuestion) {
    return getQuestion().equals(rhsQuestion);
  }

  /**
   * @return the ID of the Question being answered
   */
  public String getId() {
    return question.getId();
  }

  public QuestionSet getQuestionSet() {
    return question.getQuestionSet();
  }

  @Override
  public String toString() {
    return String.format(
        "Answer[to=%s, responses=%s]",
        getId(),
        responses.stream().map(r -> r.getOriginalInput().toString()).collect(Collectors.joining(","))
    );
  }

  public Parameter toParameter() {
    return question.toParameter().withNewDefaults((c, p) -> getValues());
  }

  /**
   * @return true if this answer has no responses recorded, i.e. the Question was skipped.
   */
  public boolean isEmpty() {
    return responses.isEmpty();
  }

}
