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

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.wizard.bld.IncrementalBuildState;
import nz.org.riskscape.wizard.bld.InvalidAnswerException;
import nz.org.riskscape.wizard.bld.PipelineChange;
import nz.org.riskscape.wizard.bld.change.BadPipelineChangeException;
import nz.org.riskscape.wizard.survey2.QuestionTree;

/**
 * Drives the survey process in an asynchronous and mutable way so that it can be reused in various contexts and hiding
 * most of the details of how the questions in the survey are turned in to a pipeline.
 */
@Slf4j
public class WizardProcessor {

  @Getter
  private final ExecutionContext executionContext;

  @Getter
  private IncrementalBuildState buildState;

  @Getter
  private List<Problem> failures = Collections.emptyList();

  private QuestionTree questionTree = QuestionTree.empty();

  /**
   * Construct a new empty processor for the given survey
   * @param context and executionContext to use for all processing, realizing and binding
   */
  public WizardProcessor(ExecutionContext context, Survey survey) {
    this.executionContext = context;
    this.buildState = IncrementalBuildState.empty(new IncrementalBuildState.Context(survey, context));
  }

  /**
   * @return if processing failed on the last change.  Note that if this is true, the most recent build state will be
   * rubbish and an undo will be required before trying again
   */
  public boolean isFailed() {
    return !failures.isEmpty();
  }

  /**
   * The project this survey is being processed against
   */
  public Project getProject() {
    return executionContext.getProject();
  }

  /**
   * The survey being processed.
   */
  public Survey getSurvey() {
    return buildState.getSurvey();
  }

  /**
   * The current QuestionTree for the processor, building a new one of the current one is empty/complete.  Note that
   * this shouldn't be used for driving the processor, just for UI/information purposes
   */
  public QuestionTree getQuestionTree() {
    if (questionTree.isComplete(buildState)) {
      questionTree = getSurvey().getQuestionTree(buildState);
    }

    return questionTree;
  }

  /**
   * @return true if there are no more questions to answer
   */
  public boolean isDone() {
    return getNextQuestions().isEmpty();
  }

  /**
   * @return a binding context for converting user input in to RiskScape objects
   */
  public BindingContext getBindingContext() {
    return executionContext.getBindingContext();
  }

  /**
   * Returns the list of questions to be considered next, hiding those that are hidden or those that must be answered
   * after a required one.
   */
  public List<Question> getNextQuestions() {
    if (isFailed()) {
      throw new IllegalStateException("Can not get questions from a failed wizard processor");
    }

    List<Question> next = getQuestionTree().getNextQuestions(buildState);
    List<Question> withoutHidden = next.stream().filter(q -> !q.isHidden()).toList();

    if (!next.isEmpty() && withoutHidden.isEmpty()) {
      throw new AssertionError("All remaining questions are hidden!");
    }

    Question firstRequired = withoutHidden.stream().filter(Question::isRequired).findFirst().orElse(null);

    // only return up to this
    if (firstRequired != null) {
      return withoutHidden.subList(0, withoutHidden.indexOf(firstRequired) + 1);
    }

    return withoutHidden;
  }

  /**
   * Update the build state to reflect the given answer, skipping any proceeding optional or hidden questions and then
   * any trailing hidden questions after
   * @return true if the change and all surrounding hidden changes succeeded
   */
  public boolean applyAnswer(Answer answer) {

    skipBefore(answer.getQuestion());

    try {
      PipelineChange pipelineChange = getSurvey().getPipelineChange(buildState, answer);

      makeChange(pipelineChange);

      return skipTrailingHiddenQuestions() && skipHiddenInNextQuestions();
    } catch (InvalidAnswerException | BadPipelineChangeException e) {
      this.failures = Arrays.asList(e.getProblem());
      return false;
    }
  }

  /**
   * Skip the given question, including any follow up hidden questions.
   * @return true if it succeeded, false if skipping failed, e.g. if it was a hidden question that couldn't be applied
   * @throws IllegalArgumentException if the question can't be skipped
   */
  public boolean skip(Question skip) {
    skipInternal(skip);

    return skipTrailingHiddenQuestions();
  }

  /**
   * Skip the given list of questions, including any follow up hidden questions.
   *
   * @return true if it succeeded, false if skipping one of the questions failed, such as a hidden question that
   * couldn't be applied
   * @throws IllegalArgumentException if one of the questions can't be skipped
   */
  public boolean skipAll(List<Question> toSkip) {
    for (Question question : toSkip) {
      if (!skipInternal(question)) {
        return false;
      }
    }

    return skipTrailingHiddenQuestions();
  }

  /**
   * Rewind the processor to the previously answered question, skipping hidden questions and clearing
   * failures.
   */
  public void undo() {
    failures = Collections.emptyList();

    /*
     * Undos job is to remove the last response from the build state, but it has to take care to cover off a few edge
     * cases as well:
     *
     *  1. Any skipped questions (hidden or optional) that trailed our last response must be removed first.
     *  2. Any skipped optional questions that preceeded the last response need to be available and offered to the user
     *     again.
     *  3. There can be no leading hidden questions, i.e. all trailing hidden questions must be skipped before
     *     a user can give a response to a question. Questions can depend on changes made by leading hidden questions
     *     (See platform#320)
     */

    buildState = buildState
            .rewind() // This covers case 1 - skips all the hidden answers and any optional questions we skipped
            .getLast() // This does the actual undo part
            .rewind(); // This covers case 2 by getting us to the point right after the last but one response was given


    // now we need to deal with case 3...

    // Get a fresh question tree so that we know what's still there to skip
    questionTree = QuestionTree.empty();
    getQuestionTree();

    // Re-skip any hidden questions we removed earlier back on
    skipTrailingHiddenQuestions();
    // There might be more hidden questions lurking in the next question set... (See platform #320)
    skipHiddenInNextQuestions();
  }

  /**
   * Apply the given change, recording failures where appropriate
   */
  private boolean makeChange(PipelineChange pipelineChange) {
    ResultOrProblems<IncrementalBuildState> changed = null;

    if (isFailed()) {
      throw new IllegalStateException("Can not change a failed wizard processor");
    }

    try {
      log.debug("Applying change {}", pipelineChange);
      changed = pipelineChange.make(buildState);
    } catch (BadPipelineChangeException ex) {
      failures = Collections.singletonList(ex.getProblem());
      buildState = buildState.append(pipelineChange, buildState.getAst(), buildState.getRealizedPipeline());
      return false;
    }

    // TODO add some context here?  or do it per-interface?
    if (changed.hasErrors()) {
      this.failures = changed.getProblems();
      buildState = buildState.append(pipelineChange, buildState.getAst(), buildState.getRealizedPipeline());
      return false;
    } else {
      buildState = changed.get();

      if (buildState.getRealizedPipeline().hasFailures()) {
        failures = buildState.getRealizedPipeline().getFailures();
        return false;
      } else {
        failures = Collections.emptyList();
      }
    }

    return !isFailed();
  }

  /**
   * If the next QuestionTree begins with hidden questions, deal with them now.
   * This makes sure that they're all processed before we try and answer the
   * first non-hidden question of the next QuestionTree - which might depend on the
   * pipeline being changed by these hidden questions (eg see ReportPhase)
   *
   * @return true if the QuestionTree is not complete, otherwise the result of skipAll(...)
   */
  private boolean skipHiddenInNextQuestions() {

    if (questionTree.isComplete(buildState)) {
      List<Question> initialHidden = getQuestionTree().getNextQuestions(buildState).stream()
          .takeWhile(Question::isHidden).toList();
      return skipAll(initialHidden);
    }
    return true;
  }

  /**
   * once we've answered/skipped  question we might be left with some hidden ones.  This cleans these up
   * so that next time we call getNextQuestions we don't end up with a list of only hidden questions.  Note that this
   * is recursive on purpose, we want to clean up trailing hidden questions at all 'depths' of a tree
   */
  private boolean skipTrailingHiddenQuestions() {
    if (isFailed()) {
      return false;
    }

    List<Question> questionsAfter = questionTree.getNextQuestions(buildState);

    // base case - all done
    if (questionsAfter.isEmpty()) {
      return true;
    }

    List<Question> trailingHidden = questionsAfter.stream().takeWhile(Question::isHidden).toList();

    if (!trailingHidden.isEmpty()) {
      return skipAll(trailingHidden);
    } else {
      return true;
    }
  }

  /**
   * Skip all the questions in the current branch of the tree until the given one
   */
  private boolean skipBefore(Question question) {
    List<Question> questions = questionTree.getNextQuestions(getBuildState());
    int questionIndex = questions.indexOf(question);

    if (questionIndex == -1) {
      throw new RuntimeException("Question not in list! " + question + " not among " + questions);
    }

    List<Question> toBeSkipped = questions.subList(0, questionIndex);

    if (!toBeSkipped.isEmpty() && toBeSkipped.stream().allMatch(Question::isHidden)) {
      throw new AssertionError("Hidden questions should only be trailing, not preceding a question");
    }

    // make sure we record all the questions we didn't answer
    return skipAll(toBeSkipped);
  }

  /**
   * Does the skip, but without the follow up skipping (which can break skipAll)
   */
  private boolean skipInternal(Question skip) {
    boolean success = makeChange(buildState.getSurvey().skip(buildState, skip));
    if (!success) {
      composeFailures(WizardProblems.get().skipFailed(skip));
      return false;
    } else {
      return true;
    }
  }

  private void composeFailures(Problem parent) {
    this.failures = Collections.singletonList(parent.withChildren(failures));
  }

}
