/*
 * 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.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;

import lombok.Getter;
import nz.org.riskscape.cli.AnsiPrintStream;
import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.cli.Table;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.i18n.TranslationContext;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.Style;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.Text;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.wizard.ask.AskAsAsker;
import nz.org.riskscape.wizard.ask.AskRequest;
import nz.org.riskscape.wizard.ask.Asker;
import nz.org.riskscape.wizard.bld.IncrementalBuildState;
import nz.org.riskscape.wizard.survey2.QuestionTree;



/**
 * High-level routines for interacting with the user to guide them through the wizard-answering process
 */
public class CliPrompter {

  /**
   * 80 chars is a bit boxy, but using the full terminal is too wide on linux
   */
  public static final int DEFAULT_DISPLAY_WIDTH = 110;

  /**
   * Try to be a little consistent with indenting menu items, where possible. But not too much indent
   * or it looks weird.
   */
  public static final int DEFAULT_INDENT_WIDTH = 16;

  /**
   * Account for the '1: ' bit that gets added to the first line of every CLI choice.
   * (It assumes single-digit choices, so not perfect alignment)
   */
  public static final int CHOICE_INDENT_WIDTH = 4;

  public enum ChooseSwitch {
    SHOW_CANCEL
  }

  public static final List<CliChoice<Boolean>> YES_NO_CHOICES = Arrays.asList(
      new CliChoice<Boolean>("yes", "y", Boolean.TRUE),
      new CliChoice<Boolean>("no", "n", Boolean.FALSE));

  @Getter
  private final Terminal terminal;
  private final List<Asker> askers;
  private final Asker defaultAsker;
  private int terminalWidth;
  private final TranslationContext translationContext;

  @Getter
  private final Messages messages;

  @Getter
  private final AnsiPrintStream out;

  public CliPrompter(Terminal terminal, List<Asker> askers, Asker defaultAsker, Messages messages,
      TranslationContext context) {
    this.terminal = terminal;
    this.askers = askers;
    this.defaultAsker = defaultAsker;
    this.messages = messages;
    this.translationContext = context;
    // this is expensive, so don't check it all the time. We could improve things by refreshing it
    // periodically to detect a resize, but it introduces a visible lag
    this.terminalWidth = terminal.getDisplayWidth();
    this.out = terminal.getAnsiOut();
  }

  /**
   * Shortcut to getOut().println(message)
   */
  public AnsiPrintStream println(Object message) {
    return out.println(message);
  }

  /**
   * Asks the user a yes/no question
   */
  public boolean askIf(String question) {
    try {
      return choose(terminal.getAnsi().text("@|white " + question + "?|@ "),
          YES_NO_CHOICES).data;

    } finally {
      out.println();
    }
  }

  /**
   * Get the user to choose one of the given items
   *
   * @param prompt an unformatted title to show the user
   * @param choiceObjects the list of java objects to choice from
   * @param labelFunc a function for generating a label from each item
   * @param modifier a function that can arbitrarily change a choice, e.g add a subtitle or a mnemonic
   * @return the chosen thing
   */
  public <T> T choose(String prompt, List<T> choiceObjects, Function<T, String> labelFunc,
      Function<CliChoice<T>, CliChoice<T>> modifier) {

    Function<T, CliChoice<T>> composed = modifier.compose((T t) -> new CliChoice<>(labelFunc.apply(t), "", t));
    List<CliChoice<T>> choices = Lists.transform(choiceObjects, t -> composed.apply(t));

    return choose(
        title(prompt),
        choices
      ).data;
  }

  public <T> T choose(String prompt, List<T> choiceObjects, Function<T, String> labelFunc) {
    return choose(prompt, choiceObjects, labelFunc, Function.identity());
  }

  /**
   * Get the user to choose one of the given items, also providing a cancel option
   *
   * @param prompt an unformatted title to show the user
   * @param cancelLabel for a cancel option - the first letter becomes its shortcut
   * @param choiceObjects the list of java objects to choice from
   * @param labelFunc a function for generating a label from each item
   * @param modifier a function that can arbitrarily change a choice, e.g add a subtitle or a mnemonic
   * @return the chosen thing, or {@link Optional#empty()} if the cancel choice was given
   */
  public <T> Optional<T> chooseOptional(
      String prompt,
      String cancelLabel,
      List<T> choiceObjects,
      Function<T, String> labelFunc,
      Function<CliChoice<T>, CliChoice<T>> modifier) {

    Function<T, CliChoice<T>> composed = modifier.compose((T t) -> new CliChoice<>(labelFunc.apply(t), "", t));

    List<CliChoice<T>> choices = Lists.transform(
        choiceObjects,
        f -> composed.apply(f) // bridging between guava and sdk Function
    );

    choices = ImmutableList.<CliChoice<T>>builder()
        .addAll(choices)
        .add(new CliChoice<>(cancelLabel, cancelLabel.substring(0, 1), null))
        .build();

    return Optional.ofNullable(choose(
        title(prompt),
        choices
    ).data);
  }

  /**
   * Asks the user a Question and gets a single unbound response (which may or may not be valid).
   */
  public ResultOrProblems<Object> ask(IncrementalBuildState buildState, Question chosen) {
    Asker asker = findAsker(buildState, chosen);

    return asker.askUnbound(AskRequest.create(this, buildState, chosen));
  }

  /**
   * We customize how we solicit answers from the user based on the type of answer that
   * the Question expects (i.e. the parameterType). E.g. when we expect an aggregation
   * expression, we can use {@link AskAsAsker} to ensure a valid
   * expression is always provided. This helps to simplify things for the user and
   * ensures we get valid responses.
   */
  public Asker findAsker(IncrementalBuildState buildState, Question chosen) {
    for (Asker asker : askers) {
      if (asker.canAsk(buildState, chosen)) {
        return asker;
      }
    }

    return defaultAsker;
  }

  public <T> CliChoice<T> choose(String prompt, List<CliChoice<T>> choices) {
    return choose(out.applyStyles(prompt), choices);
  }

  /**
   * @return the max width for displaying wizard text
   */
  private int getDisplayWidth() {
    return Math.min(DEFAULT_DISPLAY_WIDTH, terminalWidth);
  }

  /**
   * Pads out the label for a {@link CliChoice} so that their display is nicely aligned in the CLI.
   */
  private Text padChoiceLabel(Text label) {
    Text formatted = label.concat(": ");
    int padSize = DEFAULT_INDENT_WIDTH - (CHOICE_INDENT_WIDTH + formatted.getCJKAdjustedLength());

    if (padSize > 0) {
      formatted = formatted.concat(Strings.repeat(" ", padSize));
    } else if (padSize < 0) {
      // it's already over the default indent. Add a little more whitespace, just so that it
      // catches the eye better (any subsequent lines will just use DEFAULT_INDENT_WIDTH)
      formatted = formatted.concat(" ");
    }
    return formatted;
  }

  /**
   * Returns formatted Text containing a question.
   */
  public Text getPrompt(Question question) {
    String title = question.getTitle(translationContext.getLocale())
        .orElse(translationContext.getMessage("question.title.fallback", question.getName(),
            question.getParameterType()));
    Text prompt = format(title, Style.fg_white, getDisplayWidth());

    String description = question.getDescription(translationContext.getLocale())
        .orElse("");

    if (!Strings.isNullOrEmpty(description)) {
      prompt = prompt.concat(Table.LINE_BREAK)
          .concat(format(description, Style.italic, getDisplayWidth()));
    }
    return prompt;
  }

  /**
   * Applies an ANSI style to the message and line-wraps it to nicely fit the terminal width.
   */
  private Text format(String message, Style style, int wrapWidth) {
    return lineWrap(out.applyStyles(message, style), wrapWidth);
  }

  /**
   * Adds line-wrapping to the ANSI-stylized text so that it nicely fits the terminal with.
   */
  private Text lineWrap(Text message, int wrapWidth) {
    Text formatted = message;
    if (message.getCJKAdjustedLength() > 0) {
      // if the message is long, format it so it nicely spans multiple lines
      List<Text> splitMessage = Table.splitText(wrapWidth, message);
      formatted = splitMessage.get(0);
      for (Text text : splitMessage.subList(1, splitMessage.size())) {
        formatted = formatted.concat(OsUtils.LINE_SEPARATOR).concat(text);
      }
    }
    return formatted;
  }

  /**
   * Formats label to make options more user-friendly.
   */
  private <T> Text getFormattedLabel(CliChoice<T> choice, Ansi ansi) {
    String shortcut = choice.shortcut;
    String label = choice.label;
    int shortcutIndex = shortcut.equals("") ? -1 : label.indexOf(shortcut);
    Text formatted;

    // format the label + shortcut
    if (shortcutIndex == -1) {
      formatted = ansi.apply(label, Terminal.NO_STYLES);
    } else {
      String prefix = label.substring(0, shortcutIndex);
      String suffix = label.substring(shortcutIndex + shortcut.length(), label.length());

      formatted = ansi.text(String.format("%s@|blue %s|@%s", prefix, shortcut, suffix));
    }

    // format the subtitle (e.g. question description)
    if (!Strings.isNullOrEmpty(choice.subtitle)) {
      formatted = padChoiceLabel(formatted);
      String indent = Strings.repeat(" ", DEFAULT_INDENT_WIDTH);

      // if the label exceeds the default indent, split it in two. Add the extra/overhang
      // to the subtitle so that it gets line-wrapped evenly
      int indentIndex = DEFAULT_INDENT_WIDTH - CHOICE_INDENT_WIDTH;
      Text overhang = formatted.substring(indentIndex);
      formatted = formatted.substring(0, indentIndex);

      // if the subtitle is long, format it so it nicely spans multiple lines
      List<Text> splitSubtitle = Table.splitText(getDisplayWidth() - DEFAULT_INDENT_WIDTH,
          overhang.concat(ansi.apply(choice.subtitle, Arrays.asList(Style.italic))));

      formatted = formatted.concat(splitSubtitle.get(0)).concat(OsUtils.LINE_SEPARATOR);
      for (Text text : splitSubtitle.subList(1, splitSubtitle.size())) {
        formatted = formatted.concat(indent).concat(text).concat(OsUtils.LINE_SEPARATOR);
      }
    } else {
      formatted = formatted.concat(OsUtils.LINE_SEPARATOR);
    }

    return formatted;
  }

  /**
   * Ask the user to choose from the given list of choices, returning the thing that was chosen
   */
  public <T> CliChoice<T> choose(Text prompt, List<CliChoice<T>> choices) {
    try {
      println(prompt).println();

      while (true) {
        for (int i = 0; i < choices.size(); i++) {
          CliChoice<T> choice = choices.get(i);
          Text choiceText = out.applyMarkup(String.format(
              "@|white %d: |@ ",
              i + 1
          ));
          choiceText = choiceText.concat(getFormattedLabel(choice, out.getAnsi()));
          out.print(choiceText.toString());
        }

        out.println();
        String input = readline().trim();

        if (input.equals("")) {
          continue;
        }

        for (CliChoice<T> choice : choices) {
          if (input.toLowerCase().equals(choice.shortcut.toLowerCase())) {
            return choice;
          }
        }

        try {
          int inputInt = Integer.parseInt(input);
          return choices.get(inputInt - 1);
        } catch (IndexOutOfBoundsException | NumberFormatException ex) {
          out.printlnStyles("huh?", Style.fg_red);
          continue;
        }
      }
    } finally {
      out.println("");
    }
  }

  public String readlineWithTitle(String title) {
    return readlineWithTitle(out.applyStyles(title, Terminal.NO_STYLES));
  }

  public String readlineWithTitle(Text title) {
    if (title != null) {
      out.println(title);
    }
    out.print(out.applyStyles("> ", Style.fg_white, Style.bold));

    try {
      return terminal.readline();
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  public String readline() {
    return readlineWithTitle((Text) null);
  }


  public Text title(String prompt) {
    return out.applyStyles("*** " + prompt + " ***", Style.fg_white);
  }

  /**
   * Print a title out to the terminal
   * @param prompt unformatted, unmarked text to display
   */
  public AnsiPrintStream printlnTitle(String prompt) {
    return out.println(title(prompt));
  }

  /**
   * Repeatedly asks the given question until we get a valid response back
   */
  public Object askWithRepeat(IncrementalBuildState buildState, Question chosen) {
    while (true) {
      ResultOrProblems<Object> result = ask(buildState, chosen);

      if (result.hasProblems()) {
        out.println("Some problems were detected with the value given:");
        for (Problem problem : result.getProblems()) {
          // using log() here includes the [WARNING]/[ERROR] output, which is helpful
          terminal.log(problem);
        }
        out.println();
      }

      if (!result.hasErrors()) {
        return result.getWithProblemsIgnored();
      }
    }
  }

  public void displayProblems(String header, Problem... problems) {
    displayProblems(header, Arrays.asList(problems));
  }

  public void displayProblems(String header, List<Problem> problemList) {
    if (!Strings.isNullOrEmpty(header)) {
      out.println(header);
    }

    for (Problem problem : problemList) {
      out.println(messages.renderProblem(problem)).println();
    }
  }

  public void showSuccessMessage(String successMessage) {
    out.printlnMarkup("@|green [OK]|@ " + successMessage);
  }

  public void printError(String message) {
    out.printlnStyles("Error: " + message, Style.fg_red);
  }

  public Locale getLocale() {
    return terminal.getLocale();
  }

  /**
   * Prints out a breadcrumb line to the terminal to display the "parentage" for the given chosen question.
   */
  public void printBreadcrumb(QuestionTree tree, Question chosen) {
    QuestionTree.Node node = tree.findQuestion(chosen).orElse(null);

    if (node == null) {
      return;
    }

    List<String> chunks = new LinkedList<>();

    // work up from current node
    while (!node.isTopLevel()) {
      chunks.add(node.getQuestion().getSummary(getLocale()).orElse(Question.formatName(node.getQuestion().getName())));
      node = node.getParent();
    }
    chunks.add(chosen.getQuestionSet().getId());

    // flip it round so it goes from parent -> child
    Collections.reverse(chunks);

    String string = chunks.stream().collect(Collectors.joining(" >> "));
    out.printlnStyles(string, Style.italic).println();
  }

}
