/*
 * 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.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

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

import lombok.RequiredArgsConstructor;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.i18n.Message;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.problem.ProblemFactory;
import nz.org.riskscape.engine.problem.SeverityLevel;
import nz.org.riskscape.engine.util.Pair;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.ProblemException;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.wizard.bld.IncrementalBuildState;
import nz.org.riskscape.wizard.model2.ModelSurvey2;
import nz.org.riskscape.wizard.survey2.PickQuestionSet;

/**
 * Reconstructs a wizard's answers from INI config. This goes through the possible Survey
 * Questions and "answers" them in roughly the same order that an interactive wizard user
 * would have.
 *
 * Note that we deliberately use this ordered approach rather than just converting all
 * the Survey's Questions into Parameters and binding them all in one go. The reason for
 * doing so is the 'requiredness' of a wizard Parameter/Question is relatively static (i.e.
 * {@link Question#isRequired()}, whereas whether a question is applicable/should be asked
 * changes dynamically based on earlier answers (i.e. an answer can unlock follow-up questions).
 * In other words, a straight conversion from Survey to List<Parameter> up-front doesn't make
 * sense because we don't know if a Question/Parameter is truly required until we've started
 * answering questions.
 */
@RequiredArgsConstructor
public class ConfigParser {

  /**
   * Convenience for returning prefix[subkey]
   */
  public static String appendComponent(String prefix, Object subkey) {
    String subkeyString = subkey.toString();
    return new StringBuilder(prefix.length() + subkeyString.length() + 2)
        .append(prefix)
        .append('[').append(subkeyString).append(']')
        .toString();
  }

  /**
   * Scans the given `key` looking for a component immediately following `prefix` at the start of `key`
   *
   * For example, `extractNextComponent("foo[bar][baz][barry]", "foo[bar]")` returns `"baz"`
   */
  private Optional<String> extractNextComponent(String key, String prefix) {
    if (key.startsWith(prefix + '[')) {
      int endIndex = key.indexOf(']', prefix.length());
      if (endIndex != -1) {
        return Optional.of(key.substring(prefix.length() + 1, endIndex));
      }
    }
    return Optional.empty();
  }

  private static final String SURVEY_VERSION_TAG = "-wizard-v";
  private static final String SURVEY_ID_KEY = "survey";
  private final Function<String, Survey> surveyConstructor;

  /**
   * Produces a {@link IncrementalBuildState} that's the same (roughly) as if the saved INI
   * `configToLoad` had been entered as answers to the interactive wizard.
   */
  public ResultOrProblems<IncrementalBuildState> loadConfig(
      ExecutionContext context,
      Map<String, List<?>> configToLoad
  ) {
    // as of coding, we don't have an identified collection of surveys that we can fetch from.  This is a bit of a
    // half-way solution we can use until we have the ability for plugins to register their own identified collections.
    Survey survey = getSurvey(configToLoad);

    // here we want to dump a warning out to people if the version has changed, for now, let us just get rid of
    // it to suppress the surplus keys warning - we need to put this in when we do make changes
//    List<?> savedVersion =
      configToLoad.remove(VERSION_KEY);
//    if (savedVersion != null && !savedVersion.isEmpty()) {
//      int version = parseSurveyVersion(savedVersion.get(0).toString());
//
//      if (survey.getVersion() != version) {
//        // dump out a warning eventually
//      }
//    }

    // survey2 is now the only supported survey
    return loadConfig2((Survey) survey, context, configToLoad);
  }

  // package scoped for testing
  Survey getSurvey(Map<String, List<?>> configToLoad) {
    List<?> config = configToLoad.remove(SURVEY_ID_KEY);
    String id;
    if (config == null || config.isEmpty()) {
      // if no id is given we default to the ModelSurvey2.ID which is the only survey.
      id = ModelSurvey2.ID;
    } else {
      // there's a possibility of it being specified many times, but there's no easy way to dump a warning here.  Let's
      // sort this when we fetch the survey in a less kludgy way
      id = config.get(0).toString();
    }

    return surveyConstructor.apply(id);
  }

  /**
   * @return true if the user has answered questions relating to the given question-set ID.
   */
  private boolean hasAnswersToQuestionSet(Map<String, List<?>> configToLoad, String questionSetId) {
    for (String key : configToLoad.keySet()) {
      if (key.startsWith(questionSetId + ".")) {
        return true;
      }
    }
    return false;
  }

  /**
   * Tries to guess what QuestionSet should be answered next, based on the user's answers.
   * (If the user has picked a particular question-set, then there should be other answers
   * in the config for the questions in that set).
   */
  private List<String> peekQuestionSet(WizardProcessor processor, Question question,
      Map<String, List<?>> configToLoad) {
    Survey survey = (Survey) question.getSurvey();

    if (survey.isQuestionSetReplayOrdered()) {
      // order is important, so we can't just guess unless the INI parser preserves the order
      return null;
    }

    List<PickQuestionSet> choiceList = question.getChoices(processor.getBuildState()).stream()
      .map(choice -> choice.getDerivedFrom(PickQuestionSet.class))
      .toList();

    // if there's any config relating to this question-set, it means the user has answered it
    for (PickQuestionSet choice : choiceList) {
      if (hasAnswersToQuestionSet(configToLoad, choice.getQuestionSetId())) {
        return Arrays.asList(choice.getQuestionSetId());
      }
    }

    // question set was not answered, see if we can skip it
    for (PickQuestionSet choice : choiceList) {
      if (choice.isSkip()) {
        return Arrays.asList(choice.getQuestionSetId());
      }
    }

    return null;
  }

  /**
   * Produces a {@link IncrementalBuildState} that's the same (roughly) as if the saved INI
   * `configToLoad` had been entered as answers to the interactive wizard.
   */
  @SuppressWarnings("rawtypes")
  public ResultOrProblems<IncrementalBuildState> loadConfig2(
      Survey survey,
      ExecutionContext context,
      Map<String, List<?>> configToLoad

  ) {

    WizardProcessor processor = new WizardProcessor(context, survey);
    List<Problem> problems = new LinkedList<>();
    // clone this - we don't want to screw with the caller's copy
    configToLoad = new LinkedHashMap<>(configToLoad);

    while (!processor.isDone()) {
      List<Question> availableQuestions = processor.getNextQuestions();

      // always take the first one - we either have an answer or we attempt to skip it
      Question question = availableQuestions.get(0);

      try {
        Object response = deserializeResponse(configToLoad, question.getId(), question.getArity());
        // no generics on this list, it's a heterogeneous mix of types so gets in the way
        List responses;
        if (response == null && question.getParameterType().equals(PickQuestionSet.class)) {
          // try to sniff what question-set the user answered next
          responses = peekQuestionSet(processor, question, configToLoad);
        } else if (response == null || response instanceof List) {
          responses = (List) response;
        } else {
          responses = Arrays.asList(response);
        }

        if (responses != null) {
          Answer answer = Answer
            .bindAll(processor.getBindingContext(), question, responses)
            .drainWarnings(p -> problems.add(p),
                  (severity, warnings) -> Problems.foundWith(question.toParameter(), warnings))
            .getOrThrow(probs ->
              WizardProblems.get().configError().withChildren(
                  problemWith(question).withChildren(probs)
              )
            );

          processor.applyAnswer(answer);
        } else {

          // required question with no response, we need to stop parsing and run with what we've got
          // if there's no more config, then it's because the user just stopped answering, but if there is, they've
          // done something screwy and we'll give them a warning later
          if (question.isRequired() && !question.isHidden()) {

            // A user can somewhat legitimately stop/save after finishing a question set and feel
            // they've got a valid model.  This is cool for things like not answering reporting questions, but less
            // for skipping the sampling phase. Warn them if they're doing the latter
            if (!survey.isFinished(processor.getBuildState())) {
              problems.add(PROBLEMS.unfinishedSurvey(question.getId()));
            }
            break;
          }

          processor.skip(question);
        }

        if (processor.isFailed()) {
          return ResultOrProblems.failed(configError(PROBLEMS.couldNotApplyChange(question.toParameter())
              .withChildren(processor.getFailures())));
        }
      } catch (ProblemException e) {
        return e.toResult();
      }
    }

    // unconsumed config - warn the user that these weren't picked up
    // NB this used to be a slightly more robust check, but it relied on the question set api
    // having a static set of questions, which is a bit limiting.
    if (!configToLoad.keySet().isEmpty()) {
      problems.add(ParamProblems.get().ignored(configToLoad.keySet().toString())
          .withChildren(PROBLEMS.ignoredParamsTip()));
    }

    return ResultOrProblems.of(processor.getBuildState(), problems);
  }

  Object deserializeResponse(Map<String, List<?>> configToLoad, String prefix) throws ProblemException {
    return deserializeResponse(configToLoad, prefix, Optional.empty());
  }

  private Object deserializeResponse(Map<String, List<?>> configToLoad, String prefix, Range<Integer> arity)
      throws ProblemException {
    return deserializeResponse(configToLoad, prefix, Optional.of(arity));
  }

  /**
   * Build a JSON-like object from `configToLoad` by scanning for keys that begin with `prefix`.  Any keys that are used
   * are removed from `configToLoad`
   */
  // package scoped for testing
  Object deserializeResponse(Map<String, List<?>> configToLoad, String prefix, Optional<Range<Integer>> arity)
      throws ProblemException {
    boolean isSingleValued = arity.map(range -> range.hasUpperBound() && range.upperEndpoint() <= 1)
        // no arity, so don't know for sure
        .orElse(false);

    // for simple, single-valued questions, we'll get an exact match of the ID we're looking for
    List<?> exactMatch = configToLoad.remove(prefix);

    // for multi-valued questions and Composite answers, we'll need to look for subkeys,
    // e.g. foo[1] = bar or foo[bar] = baz
    Set<String> subkeys = getSubkeys(prefix, configToLoad);
    if (exactMatch != null) {
      // base case - the key has been defined exactly
      if (exactMatch.size() == 1) {
        // the user should never mix the two approaches, i.e. foo=bar and foo[1]=baz
        if (subkeys.size() > 0) {
          String originalKeys = subkeys.stream()
              .map(key -> prefix + "[" + key + "]")
              .collect(Collectors.joining(", "));
          throw new ProblemException(WizardProblems.get().configError().withChildren(
              PROBLEMS.mixedSubKeys(prefix, originalKeys)));
        }
        return exactMatch.get(0);

      } else if (isSingleValued) {
        // this question just won't support multi-valued args at all
        throw new ProblemException(WizardProblems.get().configError().withChildren(
            ParamProblems.get().wrongNumberGiven(prefix, arity.get().toString(), exactMatch.size())));
      } else {
        // not allowed with survey2 - multi-valued args need to be indexed like `[0]`
        throw new ProblemException(WizardProblems.get().configError()
            .withChildren(PROBLEMS.multipleKeysGiven(prefix)));
      }
    }

    // nothing exists for this question
    if (subkeys.isEmpty()) {
      return null;
    }

    // see if all the indices are numbers
    List<Integer> indices = subkeys
        .stream()
        .<Optional<Integer>>map(str -> {
            try {
              return Optional.<Integer>of(Integer.parseInt(str));
            } catch (NumberFormatException e) {
              return Optional.<Integer>empty();
            }
          }
        ).filter(Optional::isPresent)
         .map(Optional::get)
         .collect(Collectors.toList());

    if (subkeys.size() == indices.size()) {
      // sanity-check the question supports multi-values args
      if (isSingleValued) {
        throw new ProblemException(WizardProblems.get().configError()
            .withChildren(ParamProblems.get().wrongNumberGiven(prefix, arity.get().toString(), indices.size())));
      }

      // all the keys are numbers, so this defines a list, not a map - I hope no one is evil enough to use numbers
      // as fields/parameters
      List<Object> subResponses = new ArrayList<>(indices.size());
      for (int i = 0; i < indices.size(); i++) {
        Object subResponse = deserializeResponse(configToLoad, appendComponent(prefix, i));
        if (subResponse == null) {
          // if nothing was defined then the user has done something screwy with their numbering
          throw new ProblemException(WizardProblems.get().configError()
              .withChildren(PROBLEMS.indicesNotContiguous(prefix, i)));
        }
        subResponses.add(subResponse);
      }

      return subResponses;
    } else {
      // not a list, build a map from the subkeys
      Map<String, Object> mappedResponses = new HashMap<>(subkeys.size());
      for (String subkey : subkeys) {
        mappedResponses.put(subkey, deserializeResponse(configToLoad, appendComponent(prefix, subkey)));
      }

      return mappedResponses;
    }
  }

  private Set<String> getSubkeys(String prefix, Map<String, List<?>> configToLoad) {
    return configToLoad.keySet().stream()
      .map(key -> extractNextComponent(key, prefix))
      .filter(Optional::isPresent)
      .map(Optional::get)
      .collect(Collectors.toSet());
  }

  private boolean hasConfig(Question question) {
    if (question.isHidden()) {
      return false;
    }

    if (question.getParameterType().equals(PickQuestionSet.class)
        && !question.getSurvey().isQuestionSetReplayOrdered()) {
      // we can skip saving the 'wizard.question-choice' answers
      return false;
    }
    return true;
  }

  /**
   * @return a list of model parameters representing the wizard model's config
   */
  public List<WizardModelParameter> getModelParameters(IncrementalBuildState buildState) {
    // unroll the IncrementalBuildState stack and weed out questions that don't result in config
    List<IncrementalBuildState> orderedIbs = buildState.buildStateStream()
        .filter(ibs -> hasConfig(ibs.getQuestion()))
        .collect(Collectors.toList());

    // build state order is oldest to newest - reverse that so that the questions
    // are in the order that the user answered them
    Collections.reverse(orderedIbs);

    // turn each build state increment into one or more model parameters
    return orderedIbs.stream()
      .flatMap(ibs -> {
        Answer answer = ibs.getAnswer();
        Question question = answer.getQuestion();

        if (question.isSingleValueQuestion() && !answer.isEmpty()) {
            return toModelParameters(question.getId(), ibs, question, answer.getOriginalResponses().get(0));
        } else {
          return IntStream.range(0, answer.getOriginalResponses().size()).mapToObj(Integer::valueOf)
              .flatMap(index -> {
                Object response = answer.getOriginalResponses().get(index);
                  return toModelParameters(appendComponent(question.getId(), index), ibs, question, response);
              });
        }
      }).collect(Collectors.toList());
  }

  /**
   * @return A stream of unescaped key/value pairs, suitable for writing to a standard config file (like an ini file)
   * which, when read back in to a sorted map, can be parsed by this config parser
   */
  public Stream<Pair<String, String>> getConfigToWrite(IncrementalBuildState buildState) {
    // turn all of the answers in to a list of pairs
    Stream<Pair<String, String>> answerPairs = getModelParameters(buildState).stream()
        .map(mp -> Pair.of(mp.getName(), getDefaultValue(mp, buildState)));

    return Streams.concat(
      Arrays.asList(
        // We don't write the survey key now (there is only one survey anyway) which makes this just
        // noise in the config
        //Pair.of(SURVEY_ID_KEY, buildState.getSurvey().getId()),
        Pair.of(VERSION_KEY, getVersion(buildState))
      ).stream(),
      answerPairs
    );
  }

  private String getDefaultValue(WizardModelParameter param, IncrementalBuildState buildState) {
    List<?> defaults = param.getParameter().getDefaultValues(buildState.getBindingContext());
    // we store each response to a multi-valued question as a separate parameter,
    // so the parameter's default value should only ever hold one item
    return defaults.get(0).toString();
  }

  public String getVersion(IncrementalBuildState buildState) {
    // include the engine-ID for informational purposes. This makes it easy to map back to the actual
    // RiskScape version used to produce the model, rather than having to consult the git history
    String engineId = buildState.getProject().getEngine().getBuildInfo().getVersion();
    String surveyVersion = SURVEY_VERSION_TAG + Integer.toString(buildState.getSurvey().getVersion());
    return engineId + surveyVersion;
  }

  int parseSurveyVersion(String versionString) {
    String[] parts = versionString.split(SURVEY_VERSION_TAG);

    if (parts.length == 2) {
      try {
        return Integer.parseInt(parts[1]);
      } catch (NumberFormatException e) {
      }
    }
    // version unknown
    return 0;
  }

  private Stream<WizardModelParameter> toModelParameters(String prefix,
      IncrementalBuildState buildState,
      Question question,
      Object toSerialize) {

    if (toSerialize instanceof Map) {
      Map<?, ?> map = (Map<?, ?>) toSerialize;
      // each entry in the dict represents a response to a sub-question, so recurse over the sub-questions
      return map.entrySet().stream()
          .flatMap(entry -> {
            Question subQuestion = question.getSubQuestion(entry.getKey().toString()).orElse(question);
            return toModelParameters(appendComponent(prefix, entry.getKey()), buildState, subQuestion,
                entry.getValue());
          });
    } else if (toSerialize instanceof List) {
      // we have multiple responses so recurse over each one
      List<?> list = (List<?>) toSerialize;
      return IntStream.range(0, list.size())
          .mapToObj(i -> Integer.valueOf(i))
          .flatMap(i -> toModelParameters(appendComponent(prefix, i), buildState, question, list.get(i)));
    } else {
      // we have a single response to deal with (simple case / we've run out of recursion)
      Parameter param = Parameter.required(prefix, String.class)
          .withNewDefaults((bc, p) -> Arrays.asList(toSerialize.toString()));
      // NB: this build state is *after* this answer has been applied, whereas here we want
      // the build state *before* the question was answered
      IncrementalBuildState inputBuildState = buildState.getLast();
      return Collections.singletonList(new WizardModelParameter(param, question, inputBuildState)).stream();
    }
  }

  public interface LocalProblems extends ProblemFactory {

    @Message("Could not apply {0} as it results in an invalid pipeline")
    Problem couldNotApplyChange(Object params);

    @Message("""
    A response to ''{0}[{1}]'' is missing from your wizard configuration.  Check that your indices start from '0' and \
    that there are no gaps between numbers.
    """)
    Problem indicesNotContiguous(String prefix, int i);

    @Message("""
    A response to ''{0}'' has been given more than once.  Try removing the surplus entry from your wizard \
    configuration or if multiple responses are required use an array index to separate your responses, e.g. \
    ''{0}[0]'' and ''{0}[1]''
        """)
    Problem multipleKeysGiven(String key);

    @Message("""
    You cannot specify the same model parameter as ''{0}'' and as ''{1}''. \
    Please remove one of these
    """)
    Problem mixedSubKeys(String key, String subkeys);

    @Message("""
    Please check there are no typos in the parameter names. \
    If you have altered parameters manually, some parameter may be mutually exclusive \
    with other parameters saved in your model.
    """)
    @SeverityLevel(Severity.WARNING)
    Problem ignoredParamsTip();

    @Message("""
    You are running a saved model where not all wizard questions have been answered completely (i.e. ''{0}''). \
    This may produce model results that are incomplete.
    """)
    @SeverityLevel(Severity.WARNING)
    Problem unfinishedSurvey(String unskippable);
  }

  public static final LocalProblems PROBLEMS = Problems.get(LocalProblems.class);
  private static final String VERSION_KEY = "version";

  /**
   * Helper to wrap up the specific error reason in a nice generic message
   */
  private ResultOrProblems<IncrementalBuildState> configError(Problem reason) {
    return ResultOrProblems.failed(WizardProblems.get().configError()
        .withChildren(reason));
  }

  private Problem problemWith(Question question) {
    if (question.isHidden()) {
      // hidden questions aren't parameters that the user sees. Let's not confuse them by
      // pointing them at a non-existent parameter, and just tell them the question set was bad
      return Problems.foundWith(QuestionSet.class, question.getQuestionSet().getId());
    } else {
      return Problems.foundWith(question.toParameter());
    }
  }

  private IncrementalBuildState getOrThrow(
      ResultOrProblems<IncrementalBuildState> newBuildStateOr,
      Object affectedParams
  ) throws ProblemException {

    if (newBuildStateOr.hasErrors()) {
      configError(
          PROBLEMS.couldNotApplyChange(affectedParams)
              .withChildren(newBuildStateOr.getProblems())
      ).getOrThrow();
    }

    return newBuildStateOr.get();
  }

}
