/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE
 * or https://OpenDS.dev.java.net/OpenDS.LICENSE.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE.  If applicable,
 * add the following below this CDDL HEADER, with the fields enclosed
 * by brackets "[]" replaced with your own identifying information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2007-2008 Sun Microsystems, Inc.
 */
package org.opends.server.util.cli;



import static org.opends.messages.UtilityMessages.*;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.opends.messages.Message;
import org.opends.server.util.table.TableBuilder;
import org.opends.server.util.table.TablePrinter;
import org.opends.server.util.table.TextTablePrinter;



/**
 * An interface for incrementally building a command-line menu.
 *
 * @param <T>
 *          The type of value returned by the call-backs. Use
 *          <code>Void</code> if the call-backs do not return a
 *          value.
 */
public final class MenuBuilder<T> {

  /**
   * A simple menu option call-back which is a composite of zero or
   * more underlying call-backs.
   *
   * @param <T>
   *          The type of value returned by the call-back.
   */
  private static final class CompositeCallback<T> implements MenuCallback<T> {

    // The list of underlying call-backs.
    private final Collection<MenuCallback<T>> callbacks;



    /**
     * Creates a new composite call-back with the specified set of
     * call-backs.
     *
     * @param callbacks
     *          The set of call-backs.
     */
    public CompositeCallback(Collection<MenuCallback<T>> callbacks) {
      this.callbacks = callbacks;
    }



    /**
     * {@inheritDoc}
     */
    public MenuResult<T> invoke(ConsoleApplication app) throws CLIException {
      List<T> values = new ArrayList<T>();
      for (MenuCallback<T> callback : callbacks) {
        MenuResult<T> result = callback.invoke(app);

        if (!result.isSuccess()) {
          // Throw away all the other results.
          return result;
        } else {
          values.addAll(result.getValues());
        }
      }
      return MenuResult.success(values);
    }
  }



  /**
   * Underlying menu implementation generated by this menu builder.
   *
   * @param <T>
   *          The type of value returned by the call-backs. Use
   *          <code>Void</code> if the call-backs do not return a
   *          value.
   */
  private static final class MenuImpl<T> implements Menu<T> {

    // Indicates whether the menu will allow selection of multiple
    // numeric options.
    private final boolean allowMultiSelect;

    // The application console.
    private final ConsoleApplication app;

    // The call-back lookup table.
    private final Map<String, MenuCallback<T>> callbacks;

    // The char options table builder.
    private final TableBuilder cbuilder;

    // The call-back for the optional default action.
    private final MenuCallback<T> defaultCallback;

    // The description of the optional default action.
    private final Message defaultDescription;

    // The numeric options table builder.
    private final TableBuilder nbuilder;

    // The table printer.
    private final TablePrinter printer;

    // The menu prompt.
    private final Message prompt;

    // The menu title.
    private final Message title;

    // The maximum number of times we display the menu if the user provides
    // bad input (-1 for unlimited).
    private int nMaxTries;

    // Private constructor.
    private MenuImpl(ConsoleApplication app, Message title, Message prompt,
        TableBuilder ntable, TableBuilder ctable, TablePrinter printer,
        Map<String, MenuCallback<T>> callbacks, boolean allowMultiSelect,
        MenuCallback<T> defaultCallback, Message defaultDescription,
        int nMaxTries) {
      this.app = app;
      this.title = title;
      this.prompt = prompt;
      this.nbuilder = ntable;
      this.cbuilder = ctable;
      this.printer = printer;
      this.callbacks = callbacks;
      this.allowMultiSelect = allowMultiSelect;
      this.defaultCallback = defaultCallback;
      this.defaultDescription = defaultDescription;
      this.nMaxTries = nMaxTries;
    }



    /**
     * {@inheritDoc}
     */
    public MenuResult<T> run() throws CLIException {
      // The validation call-back which will be used to determine the
      // action call-back.
      ValidationCallback<MenuCallback<T>> validator =
        new ValidationCallback<MenuCallback<T>>() {

        public MenuCallback<T> validate(ConsoleApplication app, String input) {
          String ninput = input.trim();

          if (ninput.length() == 0) {
            if (defaultCallback != null) {
              return defaultCallback;
            } else if (allowMultiSelect) {
              app.println();
              app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
              app.println();
              return null;
            } else {
              app.println();
              app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
              app.println();
              return null;
            }
          } else if (allowMultiSelect) {
            // Use a composite call-back to collect all the results.
            List<MenuCallback<T>> cl = new ArrayList<MenuCallback<T>>();
            for (String value : ninput.split(",")) {
              // Make sure that there are no duplicates.
              String nvalue = value.trim();
              Set<String> choices = new HashSet<String>();

              if (choices.contains(nvalue)) {
                app.println();
                app.println(ERR_MENU_BAD_CHOICE_MULTI_DUPE.get(value));
                app.println();
                return null;
              } else if (!callbacks.containsKey(nvalue)) {
                app.println();
                app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
                app.println();
                return null;
              } else {
                cl.add(callbacks.get(nvalue));
                choices.add(nvalue);
              }
            }

            return new CompositeCallback<T>(cl);
          } else if (!callbacks.containsKey(ninput)) {
            app.println();
            app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
            app.println();
            return null;
          } else {
            return callbacks.get(ninput);
          }
        }
      };

      // Determine the correct choice prompt.
      Message promptMsg;
      if (allowMultiSelect) {
        if (defaultDescription != null) {
          promptMsg = INFO_MENU_PROMPT_MULTI_DEFAULT.get(defaultDescription);
        } else {
          promptMsg = INFO_MENU_PROMPT_MULTI.get();
        }
      } else {
        if (defaultDescription != null) {
          promptMsg = INFO_MENU_PROMPT_SINGLE_DEFAULT.get(defaultDescription);
        } else {
          promptMsg = INFO_MENU_PROMPT_SINGLE.get();
        }
      }

      // If the user selects help then we need to loop around and
      // display the menu again.
      while (true) {
        // Display the menu.
        if (title != null) {
          app.println(title);
          app.println();
        }

        if (prompt != null) {
          app.println(prompt);
          app.println();
        }

        if (nbuilder.getTableHeight() > 0) {
          nbuilder.print(printer);
          app.println();
        }

        if (cbuilder.getTableHeight() > 0) {
          TextTablePrinter cprinter =
            new TextTablePrinter(app.getErrorStream());
          cprinter.setDisplayHeadings(false);
          int sz = String.valueOf(nbuilder.getTableHeight()).length() + 1;
          cprinter.setIndentWidth(4);
          cprinter.setColumnWidth(0, sz);
          cprinter.setColumnWidth(1, 0);
          cbuilder.print(cprinter);
          app.println();
        }

        // Get the user's choice.
        MenuCallback<T> choice;

        if (nMaxTries != -1)
        {
          choice = app.readValidatedInput(promptMsg, validator, nMaxTries);
        }
        else
        {
          choice = app.readValidatedInput(promptMsg, validator);
        }

        // Invoke the user's selected choice.
        MenuResult<T> result = choice.invoke(app);

        // Determine if the help needs to be displayed, display it and
        // start again.
        if (!result.isAgain()) {
          return result;
        } else {
          app.println();
          app.println();
        }
      }
    }
  }



  /**
   * A simple menu option call-back which does nothing but return the
   * provided menu result.
   *
   * @param <T>
   *          The type of result returned by the call-back.
   */
  private static final class ResultCallback<T> implements MenuCallback<T> {

    // The result to be returned by this call-back.
    private final MenuResult<T> result;



    // Private constructor.
    private ResultCallback(MenuResult<T> result) {
      this.result = result;
    }



    /**
     * {@inheritDoc}
     */
    public MenuResult<T> invoke(ConsoleApplication app) throws CLIException {
      return result;
    }

  }

  // The multiple column display threshold.
  private int threshold = -1;

  // Indicates whether the menu will allow selection of multiple
  // numeric options.
  private boolean allowMultiSelect = false;

  // The application console.
  private final ConsoleApplication app;

  // The char option call-backs.
  private final List<MenuCallback<T>> charCallbacks =
    new ArrayList<MenuCallback<T>>();

  // The char option keys (must be single-character messages).
  private final List<Message> charKeys = new ArrayList<Message>();

  // The synopsis of char options.
  private final List<Message> charSynopsis = new ArrayList<Message>();

  // Optional column headings.
  private final List<Message> columnHeadings = new ArrayList<Message>();

  // Optional column widths.
  private final List<Integer> columnWidths = new ArrayList<Integer>();

  // The call-back for the optional default action.
  private MenuCallback<T> defaultCallback = null;

  // The description of the optional default action.
  private Message defaultDescription = null;

  // The numeric option call-backs.
  private final List<MenuCallback<T>> numericCallbacks =
    new ArrayList<MenuCallback<T>>();

  // The numeric option fields.
  private final List<List<Message>> numericFields =
    new ArrayList<List<Message>>();

  // The menu title.
  private Message title = null;

  // The menu prompt.
  private Message prompt = null;

  // The maximum number of times that we allow the user to provide an invalid
  // answer (-1 if unlimited).
  private int nMaxTries = -1;

  /**
   * Creates a new menu.
   *
   * @param app
   *          The application console.
   */
  public MenuBuilder(ConsoleApplication app) {
    this.app = app;
  }



  /**
   * Creates a "back" menu option. When invoked, this option will
   * return a {@code MenuResult.cancel()} result.
   *
   * @param isDefault
   *          Indicates whether this option should be made the menu
   *          default.
   */
  public void addBackOption(boolean isDefault) {
    addCharOption(INFO_MENU_OPTION_BACK_KEY.get(), INFO_MENU_OPTION_BACK.get(),
        MenuResult.<T> cancel());

    if (isDefault) {
      setDefault(INFO_MENU_OPTION_BACK_KEY.get(), MenuResult.<T> cancel());
    }
  }



  /**
   * Creates a "cancel" menu option. When invoked, this option will
   * return a {@code MenuResult.cancel()} result.
   *
   * @param isDefault
   *          Indicates whether this option should be made the menu
   *          default.
   */
  public void addCancelOption(boolean isDefault) {
    addCharOption(INFO_MENU_OPTION_CANCEL_KEY.get(), INFO_MENU_OPTION_CANCEL
        .get(), MenuResult.<T> cancel());

    if (isDefault) {
      setDefault(INFO_MENU_OPTION_CANCEL_KEY.get(), MenuResult.<T> cancel());
    }
  }



  /**
   * Adds a menu choice to the menu which will have a single letter as
   * its key.
   *
   * @param c
   *          The single-letter message which will be used as the key
   *          for this option.
   * @param description
   *          The menu option description.
   * @param callback
   *          The call-back associated with this option.
   */
  public void addCharOption(Message c, Message description,
      MenuCallback<T> callback) {
    charKeys.add(c);
    charSynopsis.add(description);
    charCallbacks.add(callback);
  }



  /**
   * Adds a menu choice to the menu which will have a single letter as
   * its key and which returns the provided result.
   *
   * @param c
   *          The single-letter message which will be used as the key
   *          for this option.
   * @param description
   *          The menu option description.
   * @param result
   *          The menu result which should be returned by this menu
   *          choice.
   */
  public void addCharOption(Message c, Message description,
      MenuResult<T> result) {
    addCharOption(c, description, new ResultCallback<T>(result));
  }



  /**
   * Creates a "help" menu option which will use the provided help
   * call-back to display help relating to the other menu options.
   * When the help menu option is selected help will be displayed and
   * then the user will be shown the menu again and prompted to enter
   * a choice.
   *
   * @param callback
   *          The help call-back.
   */
  public void addHelpOption(final HelpCallback callback) {
    MenuCallback<T> wrapper = new MenuCallback<T>() {

      public MenuResult<T> invoke(ConsoleApplication app) throws CLIException {
        app.println();
        callback.display(app);
        return MenuResult.again();
      }

    };

    addCharOption(INFO_MENU_OPTION_HELP_KEY.get(), INFO_MENU_OPTION_HELP.get(),
        wrapper);
  }



  /**
   * Adds a menu choice to the menu which will have a numeric key.
   *
   * @param description
   *          The menu option description.
   * @param callback
   *          The call-back associated with this option.
   * @param extraFields
   *          Any additional fields associated with this menu option.
   * @return Returns the number associated with menu choice.
   */
  public int addNumberedOption(Message description, MenuCallback<T> callback,
      Message... extraFields) {
    List<Message> fields = new ArrayList<Message>();
    fields.add(description);
    if (extraFields != null) {
      fields.addAll(Arrays.asList(extraFields));
    }

    numericFields.add(fields);
    numericCallbacks.add(callback);

    return numericCallbacks.size();
  }



  /**
   * Adds a menu choice to the menu which will have a numeric key and
   * which returns the provided result.
   *
   * @param description
   *          The menu option description.
   * @param result
   *          The menu result which should be returned by this menu
   *          choice.
   * @param extraFields
   *          Any additional fields associated with this menu option.
   * @return Returns the number associated with menu choice.
   */
  public int addNumberedOption(Message description, MenuResult<T> result,
      Message... extraFields) {
    return addNumberedOption(description, new ResultCallback<T>(result),
        extraFields);
  }



  /**
   * Creates a "quit" menu option. When invoked, this option will
   * return a {@code MenuResult.quit()} result.
   */
  public void addQuitOption() {
    addCharOption(INFO_MENU_OPTION_QUIT_KEY.get(), INFO_MENU_OPTION_QUIT.get(),
        MenuResult.<T> quit());
  }



  /**
   * Sets the flag which indicates whether or not the menu will permit
   * multiple numeric options to be selected at once. Users specify
   * multiple choices by separating them with a comma. The default is
   * <code>false</code>.
   *
   * @param allowMultiSelect
   *          Indicates whether or not the menu will permit multiple
   *          numeric options to be selected at once.
   */
  public void setAllowMultiSelect(boolean allowMultiSelect) {
    this.allowMultiSelect = allowMultiSelect;
  }



  /**
   * Sets the optional column headings. The column headings will be
   * displayed above the menu options.
   *
   * @param headings
   *          The optional column headings.
   */
  public void setColumnHeadings(Message... headings) {
    this.columnHeadings.clear();
    if (headings != null) {
      this.columnHeadings.addAll(Arrays.asList(headings));
    }
  }



  /**
   * Sets the optional column widths. A value of zero indicates that
   * the column should be expandable, a value of <code>null</code>
   * indicates that the column should use its default width.
   *
   * @param widths
   *          The optional column widths.
   */
  public void setColumnWidths(Integer... widths) {
    this.columnWidths.clear();
    if (widths != null) {
      this.columnWidths.addAll(Arrays.asList(widths));
    }
  }



  /**
   * Sets the optional default action for this menu. The default
   * action call-back will be invoked if the user does not specify an
   * option and just presses enter.
   *
   * @param description
   *          A short description of the default action.
   * @param callback
   *          The call-back associated with the default action.
   */
  public void setDefault(Message description, MenuCallback<T> callback) {
    defaultCallback = callback;
    defaultDescription = description;
  }



  /**
   * Sets the optional default action for this menu. The default
   * action call-back will be invoked if the user does not specify an
   * option and just presses enter.
   *
   * @param description
   *          A short description of the default action.
   * @param result
   *          The menu result which should be returned by default.
   */
  public void setDefault(Message description, MenuResult<T> result) {
    setDefault(description, new ResultCallback<T>(result));
  }



  /**
   * Sets the number of numeric options required to trigger
   * multiple-column display. A negative value (the default) indicates
   * that the numeric options will always be displayed in a single
   * column. A value of 0 indicates that numeric options will always
   * be displayed in multiple columns.
   *
   * @param threshold
   *          The number of numeric options required to trigger
   *          multiple-column display.
   */
  public void setMultipleColumnThreshold(int threshold) {
    this.threshold = threshold;
  }



  /**
   * Sets the optional menu prompt. The prompt will be displayed above
   * the menu. Menus do not have a prompt by default.
   *
   * @param prompt
   *          The menu prompt, or <code>null</code> if there is not
   *          prompt.
   */
  public void setPrompt(Message prompt) {
    this.prompt = prompt;
  }



  /**
   * Sets the optional menu title. The title will be displayed above
   * the menu prompt. Menus do not have a title by default.
   *
   * @param title
   *          The menu title, or <code>null</code> if there is not
   *          title.
   */
  public void setTitle(Message title) {
    this.title = title;
  }



  /**
   * Creates a menu from this menu builder.
   *
   * @return Returns the new menu.
   */
  public Menu<T> toMenu() {
    TableBuilder nbuilder = new TableBuilder();
    Map<String, MenuCallback<T>> callbacks =
      new HashMap<String, MenuCallback<T>>();

    // Determine whether multiple columns should be used for numeric
    // options.
    boolean useMultipleColumns = false;
    if (threshold >= 0 && numericCallbacks.size() >= threshold) {
      useMultipleColumns = true;
    }

    // Create optional column headers.
    if (!columnHeadings.isEmpty()) {
      nbuilder.appendHeading();
      for (Message heading : columnHeadings) {
        if (heading != null) {
          nbuilder.appendHeading(heading);
        } else {
          nbuilder.appendHeading();
        }
      }

      if (useMultipleColumns) {
        nbuilder.appendHeading();
        for (Message heading : columnHeadings) {
          if (heading != null) {
            nbuilder.appendHeading(heading);
          } else {
            nbuilder.appendHeading();
          }
        }
      }
    }

    // Add the numeric options first.
    int sz = numericCallbacks.size();
    int rows = sz;

    if (useMultipleColumns) {
      // Display in two columns the first column should contain half
      // the options. If there are an odd number of columns then the
      // first column should contain an additional option (e.g. if
      // there are 23 options, the first column should contain 12
      // options and the second column 11 options).
      rows /= 2;
      rows += sz % 2;
    }

    for (int i = 0, j = rows; i < rows; i++, j++) {
      nbuilder.startRow();
      nbuilder.appendCell(INFO_MENU_NUMERIC_OPTION.get(i + 1));

      for (Message field : numericFields.get(i)) {
        if (field != null) {
          nbuilder.appendCell(field);
        } else {
          nbuilder.appendCell();
        }
      }

      callbacks.put(String.valueOf(i + 1), numericCallbacks.get(i));

      // Second column.
      if (useMultipleColumns && (j < sz)) {
        nbuilder.appendCell(INFO_MENU_NUMERIC_OPTION.get(j + 1));

        for (Message field : numericFields.get(j)) {
          if (field != null) {
            nbuilder.appendCell(field);
          } else {
            nbuilder.appendCell();
          }
        }

        callbacks.put(String.valueOf(j + 1), numericCallbacks.get(j));
      }
    }

    // Add the char options last.
    TableBuilder cbuilder = new TableBuilder();
    for (int i = 0; i < charCallbacks.size(); i++) {
      char c = charKeys.get(i).charAt(0);
      Message option = INFO_MENU_CHAR_OPTION.get(c);

      cbuilder.startRow();
      cbuilder.appendCell(option);
      cbuilder.appendCell(charSynopsis.get(i));

      callbacks.put(String.valueOf(c), charCallbacks.get(i));
    }

    // Configure the table printer.
    TextTablePrinter printer = new TextTablePrinter(app.getErrorStream());

    if (columnHeadings.isEmpty()) {
      printer.setDisplayHeadings(false);
    } else {
      printer.setDisplayHeadings(true);
      printer.setHeadingSeparatorStartColumn(1);
    }

    printer.setIndentWidth(4);
    if (columnWidths.isEmpty()) {
      printer.setColumnWidth(1, 0);
      if (useMultipleColumns) {
        printer.setColumnWidth(3, 0);
      }
    } else {
      for (int i = 0; i < columnWidths.size(); i++) {
        Integer j = columnWidths.get(i);
        if (j != null) {
          // Skip the option key column.
          printer.setColumnWidth(i + 1, j);

          if (useMultipleColumns) {
            printer.setColumnWidth(i + 2 + columnWidths.size(), j);
          }
        }
      }
    }

    return new MenuImpl<T>(app, title, prompt, nbuilder, cbuilder, printer,
        callbacks, allowMultiSelect, defaultCallback, defaultDescription,
        nMaxTries);
  }

  /**
   * Sets the maximum number of tries that the user can provide an invalid
   * value in the menu. -1 for unlimited tries (the default).  If this limit is
   * reached a CLIException will be thrown.
   * @param nTries the maximum number of tries.
   */
  public void setMaxTries(int nTries)
  {
    nMaxTries = nTries;
  }
}
