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

import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.collect.Streams;

import lombok.AccessLevel;
import lombok.Data;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.pipeline.PipelineExecutor;
import nz.org.riskscape.engine.pipeline.RealizedPipeline;
import nz.org.riskscape.engine.pipeline.RealizedStep;
import nz.org.riskscape.engine.pipeline.SinkConstructor;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.PipelineDeclaration;
import nz.org.riskscape.rl.ast.PipelineDeclaration.Found;
import nz.org.riskscape.rl.ast.StepDeclaration;
import nz.org.riskscape.wizard.Answer;
import nz.org.riskscape.wizard.EmptyQuestionSet;
import nz.org.riskscape.wizard.Question;
import nz.org.riskscape.wizard.QuestionSet;
import nz.org.riskscape.wizard.Survey;
import nz.org.riskscape.wizard.bld.change.NoChange;
import nz.org.riskscape.wizard.bld.loc.ChangeLocation;

/**
 * The results of building a pipeline incrementally in response to wizard {@link Answer}s being supplied and
 * {@link PipelineChange}s being made.  This is a recursive data structure in that each state points to a previous state
 * that was how the pipeline looked before the change was made.
 */
@Data @RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class IncrementalBuildState {

  /**
   * The non-answer for an empty question.  Again, used instead of null in various situations.
   */
  public static final String START_STEP_NAME = "start";
  /**
   * The QuestionSet associated with an empty build state, i.e. when {@link #isEmpty()} is true
   */
  public static final QuestionSet EMPTY_QUESTION_SET = new EmptyQuestionSet("empty", Survey.EMPTY_SURVEY);

  /**
   * The {@link Question} associated with an empty build state, i.e. when {@link #isEmpty()} is true
   */
  public static final Question EMPTY_QUESTION = new Question("", Void.class)
      .optionalOne()
      .inSet(EMPTY_QUESTION_SET);


  public static final Answer EMPTY_ANSWER = new Answer(EMPTY_QUESTION, Collections.emptyList());

  /**
   * A set of {@link RealizedStep} that is returned from a QuestionSet's applicability method when it applies to an
   * empty pipeline
   */
  public static final Set<RealizedStep> START_STEPS_SET = Collections.singleton(
      RealizedStep.emptyInput(START_STEP_NAME, Struct.EMPTY_STRUCT)
  );

  /**
   * All the unchanging elements of the {@link IncrementalBuildState}, wrapped up in a class for ease of
   * copying/referencing.
   */
  @Data @RequiredArgsConstructor
  public static class Context {
    private final Survey survey;
    private final ExecutionContext executionContext;
  }

  /**
   * Create a new, empty incremental build state using the given dependencies.  This is the start state for building
   * a pipeline incrementally via a {@link Survey}.
   */
  public static IncrementalBuildState empty(Context context) {
    return new IncrementalBuildState(
        context,
        null,
        new NoChange(EMPTY_ANSWER),
        PipelineDeclaration.EMPTY,
        new RealizedPipeline(
            context.executionContext,
            PipelineDeclaration.EMPTY,
            Collections.emptyList()
        )
    ) {

      @Override
      public IncrementalBuildState getLast() {
        return this;
      }

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

  /**
   * Various static dependencies, i.e. this is the same object for all members of the change stack
   */
  private final Context context;

  /**
   * The previous thing we built upon - will return `this` if this is the empty build state
   */
  private final IncrementalBuildState last;

  /**
   * The change that was responsible for this state being created
   */
  private final PipelineChange createdBy;

  /**
   * The current pipeline ast.
   */
  private final PipelineDeclaration ast;

  /**
   * A small chunk of the overall pipeline that was realized for this state to be proven valid.
   */
  private final RealizedPipeline realizedPipeline;

  private IncrementalBuildState(IncrementalBuildState previous, PipelineChange createdBy,
      PipelineDeclaration newAst, RealizedPipeline realizedPipeline) {

    this.last = previous;
    this.createdBy = createdBy;
    this.context = last.context;
    this.ast = newAst;
    this.realizedPipeline = realizedPipeline;
  }

  /**
   * Return a new {@link IncrementalBuildState} that resulted in applying the given change to this build state.
   * @param newCreatedBy The {@link PipelineChange} that is responsible
   * @param newAst The pipeline ast that the {@link PipelineChange} created
   * @param newPipeline An illustrative fragment of the pipeline, used for validation purposes (so far)
   * @throws IllegalArgumentException if the change references an {@link Answer} for a {@link Question} that has already
   * been answered
   */
  public IncrementalBuildState append(
      @NonNull PipelineChange newCreatedBy,
      @NonNull PipelineDeclaration newAst,
      @NonNull RealizedPipeline newPipeline
  ) throws IllegalArgumentException {
    // check for this answer being given already
    buildStateStream().forEach(ptr -> {
      if (ptr.getQuestion().equals(newCreatedBy.getQuestion())
          && ptr.getQuestionSet().equals(newCreatedBy.getQuestionSet())
      ) {
        // dupe, this is bad
        throw new IllegalArgumentException("already answered - " + ptr.getAnswer());
      }
    });

    return new IncrementalBuildState(this, newCreatedBy, newAst, newPipeline);
  }

  /**
   * @return an {@link Iterator} that traverses the build state's stack, from latest change to oldest, excluding
   * the empty state
   */
  public Iterator<IncrementalBuildState> buildStateIterator() {
    return new Iterator<IncrementalBuildState>() {

      IncrementalBuildState ptr = IncrementalBuildState.this;

      @Override
      public boolean hasNext() {
        return !ptr.isEmpty();
      }

      @Override
      public IncrementalBuildState next() {
        IncrementalBuildState toReturn = ptr;
        ptr = ptr.getLast();
        return toReturn;
      }
    };
  }

  /**
   * @return a {@link Stream} that traverses the build state's stack, from latest change to oldest, excluding
   * the empty state
   */
  public Stream<IncrementalBuildState> buildStateStream() {
    return Streams.stream(buildStateIterator());
  }

  /**
   * @return true if this state has resulted in an invalid pipeline.  Please try again.
   */
  public boolean isInvalid() {
    return buildStateStream().anyMatch(ibs -> ibs.realizedPipeline.hasFailures());
  }

  /**
   * Attempts to return the end step that resulted from this state.
   *
   * Will return the single (non-sink) end step if one exists.
   * Otherwise if there are only {@link SinkConstructor} end steps then their dependency will be returned if that
   * is the same for all of the sinks. Otherwise the IllegalStateException is thrown.
   *
   * @throws IllegalStateException If there is none, or there were multiple end steps,
   * then this throws an exception
   */
  public RealizedStep getRealizedStep() throws IllegalStateException {
    Set<RealizedStep> endSteps = getRealizedPipeline().getEndSteps();

    // we'll never chain off a sink, so filter these out.  This makes it simpler to pick a single step
    List<RealizedStep> nonSinkEndSteps = endSteps.stream()
        .filter(step -> !step.getStepType().equals(SinkConstructor.class))
        .collect(Collectors.toList());

    if (nonSinkEndSteps.size() == 1) {
      // there is only one non sink step, that's our last step.
      return nonSinkEndSteps.iterator().next();
    } else if (nonSinkEndSteps.isEmpty() && ! endSteps.isEmpty()) {
      // we have end steps, but they are all sinks.
      // maybe they all have the same dependency, we could return that.
      RealizedStep dependsOn = null;
      for (RealizedStep sink: endSteps) {
        if (dependsOn == null) {
          dependsOn = sink.getDependencies().get(0);
        } else {
          if (dependsOn != sink.getDependencies().get(0)) {
            // difference dependencies, bail from here.
            dependsOn = null;
            break;
          }
        }
      }
      if (dependsOn != null) {
        return dependsOn;
      }
    }
    throw new IllegalStateException("can not return a single step - previous state had multiple outputs");
  }

  /**
   * @return the input scope Struct based on `location`, if known.  Returns EMPTY_STRUCT if it couldn't be determined.
   */
  public Struct getInputStruct(ChangeLocation location) {
    Optional<Found> found = location.findOne(this);
    if (!found.isPresent()) {
      return Struct.EMPTY_STRUCT;
    }

    // find the realized step that matches the question's change location, based on the AST
    StepDeclaration targetStep = found.get().getStep();
    Optional<RealizedStep> realizedStep = getRealizedPipeline().getRealizedSteps().stream()
        .filter(step -> targetStep.equals(step.getAst()))
        .findFirst();

    return realizedStep.map(step -> step.getProduces()).orElse(Struct.EMPTY_STRUCT);
  }

  /**
   * @return the input scope Struct available for this question, if known.  Returns EMPTY_STRUCT if it couldn't be
   * determined.
   */
  public Struct getInputStruct(Question question) {
    return getInputStruct(question.getChangeLocation());
  }

  /**
   * @return true if this build state is the first, empty state.
   */
  public boolean isEmpty() {
    // overriden by the special empty state to return true
    return false;
  }

  public Optional<Answer> getAnswer(String questionSetId, String questionName) {
    return buildStateStream()
        .filter(ptr ->
          ptr.getQuestionSet().getId().equals(questionSetId) && ptr.getQuestion().getName().equals(questionName)
        )
        .findFirst()
        .map(IncrementalBuildState::getAnswer);
  }

  /**
   * @return return an answer for the given question, based on the question's id (rather than testing object ==)
   */
  public Optional<Answer> getAnswer(Question q) {
    return getAnswer(q.getQuestionSet().getId(), q.getName());
  }

  /**
   * Optional-style version of getAnswerTo
   */
  public <T> Optional<T> getResponse(String questionSetId, String questionName, Class<T> expectedResponseType) {
    return getAnswer(questionSetId, questionName).map(ans -> ans.getValueAs(expectedResponseType));
  }

  /**
   * @return the responses recorded for a given question
   */
  public <T> List<T> getAnswersTo(String questionSetId, String questionName, Class<T> expectedType) {
    //   safety check: make sure a question exists with that name
    return getAnswer(questionSetId, questionName)
        .map(ans -> ans.getValuesAs(expectedType))
        .orElse(Collections.<T>emptyList());
  }

  /**
   * @return the response recorded for a question, throwing an exception if the question wasn't a single-value response
   * type question
   */
  public <T> T getAnswerTo(String questionSetId, String questionName, Class<T> expectedType) {
    //  safety check: make sure a question exists with that name and is single value
    return getAnswer(questionSetId, questionName)
        .map(answer -> answer.getValueAs(expectedType))
        .orElse(null);
  }

  /**
   * Helper method for attempting to construct and realize a pipeline from the given AST.
   * NB: We can probably drop the {@link ResultOrProblems} from this - a RealizedPipeline has its own list of errors.
   */
  public ResultOrProblems<RealizedPipeline> realizePipeline(PipelineDeclaration realizeThisAst) {
    return ResultOrProblems.of(this.context.getExecutionContext().realize(realizeThisAst));
  }

  /**
   * @return the {@link Answer} that resulted in this build state
   */
  public Answer getAnswer() {
    return createdBy.getAnswer();
  }

  /**
   * @return the QuestionSet linked to this build state, e.g the question and answer belonged to this QuestionSet
   */
  public QuestionSet getQuestionSet() {
    return createdBy.getQuestionSet();
  }

  /**
   * @return the {@link Question} who's answer resulted in the build state
   */
  public Question getQuestion() {
    return createdBy.getQuestion();
  }

  /**
   * @return the {@link Project} associated with this build state
   */
  public Project getProject() {
    return this.context.executionContext.getProject();
  }

  /**
   * @return all that answers in the buildstate, in the order they were most recently given, i.e the head of the list is
   * the same as this build state's {@link #getAnswer()}
   */
  public List<Answer> getAllAnswers() {
    return buildStateStream()
        .map(IncrementalBuildState::getAnswer)
        .collect(Collectors.toList());
  }

  /**
   * Determine whether a question set with the given id appears in this build state's change stack, indicating that
   * the question set has been responded to or skipped
   */
  public boolean isQuestionSetAnswered(String questionSetId) {
    return this.buildStateStream().anyMatch(ibs -> ibs.getQuestionSet().getId().equals(questionSetId));
  }

  /**
   * Determine whether a question has been asked, regardless of whether the user provided a response or just
   * chose to skip the question.
   *
   * This method accepts id strings, rather than the actual objects, to allow other questions to test for their
   * presence without needing a reference to the other question
   *
   * @return true if the given question was answered, regardless of whether a response was given.
   */
  public boolean wasAsked(String questionSetId, String questionName) {
    return getAnswer(questionSetId, questionName).isPresent();
  }

  /**
   * Returns true if the last answer was the given value. This only checks the answer supplied for
   * single-valued Questions, so it will always return false if the question is not single valued.
   */
  public boolean lastAnswerWas(Object value) {
    return getQuestion().isSingleValueQuestion() && value.equals(getAnswer().getValueAs(Object.class));
  }

  /**
   * Determine whether a response has been given to a question by its name local to the current question set.
   *
   * This method accepts id strings, rather than the actual objects, to allow other questions to test for their
   * presence without needing a reference to the other question
   *
   * @return true if the given question was answered and at least one response was given.
   */
  public boolean isResponseGiven(String questionSetId, String questionName) {
    return getAnswer(questionSetId, questionName)
        .map(answer -> !answer.isEmpty())
        .orElse(false);
  }

  /**
   * Functional style if that calls the callback if a response was given to the nominated question, returning true
   * if the callback was called
   */
  public <T> boolean ifResponseGiven(String questionSetId, String questionName, Class<T> clazz, Consumer<T> callback) {
    Optional<Answer> answer = getAnswer(questionSetId, questionName);
    if (answer.isPresent() && !answer.get().isEmpty()) {
      callback.accept(answer.get().getValueAs(clazz));
      return true;
    } else {
      return false;
    }
  }

  /**
   * @return the {@link BindingContext} associated with this pipeline build state's context
   */
  public BindingContext getBindingContext() {
    return getContext().getExecutionContext().getBindingContext();
  }

  /**
   * @return the {@link PipelineExecutor} associated with this pipeline build state's context
   */
  public PipelineExecutor getPipelineExecutor() {
    return getContext().getExecutionContext().getPipelineExecutor();
  }

  public Survey getSurvey() {
    return context.survey;
  }

  @Override
  public String toString() {
    return String.format("IncrementalBuildState(last-answer=%s)", createdBy.getAnswer());
  }

  /**
   * @return true if the build state was in response to a human entering some information, i.e not skipped and not
   * hidden
   */
  public boolean isResponseGiven() {
    return !getAnswer().isEmpty() && !getQuestion().isHidden();
  }

  /**
   * Removes trailing build states that have no response.
   * These could either be hidden questions or optional questions that were not answered.
   *
   * @return the last build with a response, or the empty build state if none found
   */
  public IncrementalBuildState rewind() {
    IncrementalBuildState buildState = this;
    while (!buildState.isEmpty() && !buildState.isResponseGiven()) {
      buildState = buildState.getLast();
    }

    return buildState;
  }
}
