/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 */

/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

/*
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

package org.opensearch.cli;

import java.io.BufferedReader;
import java.io.Console;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Locale;

/**
 * A Terminal wraps access to reading input and writing output for a cli.
 * <p>
 * The available methods are similar to those of {@link Console}, with the ability
 * to read either normal text or a password, and the ability to print a line
 * of text. Printing is also gated by the {@link Verbosity} of the terminal,
 * which allows {@link #println(Verbosity,String)} calls which act like a logger,
 * only actually printing if the verbosity level of the terminal is above
 * the verbosity of the message.
 * @see ConsoleTerminal
 * @see SystemTerminal
*/
public abstract class Terminal {

    /** Writer to standard error - not supplied by the {@link Console} API, so we share with subclasses */
    private static final PrintWriter ERROR_WRITER = newErrorWriter();

    /** The default terminal implementation, which will be a console if available, or stdout/stderr if not. */
    public static final Terminal DEFAULT = ConsoleTerminal.isSupported() ? new ConsoleTerminal() : new SystemTerminal();

    @SuppressForbidden(reason = "Writer for System.err")
    private static PrintWriter newErrorWriter() {
        return new PrintWriter(System.err);
    }

    /** Defines the available verbosity levels of messages to be printed.*/
    public enum Verbosity {
        /** always printed */
        SILENT,
        /** printed when no options are given to cli */
        NORMAL,
        /** printed only when cli is passed verbose option */
        VERBOSE
    }

    /** The current verbosity for the terminal, defaulting to {@link Verbosity#NORMAL}. */
    private Verbosity verbosity = Verbosity.NORMAL;

    /** The newline separator used when calling println. */
    private final String lineSeparator;

    /** Constructs a new terminal with the given line separator.
     * @param lineSeparator the line separator to use when calling println
     * */
    protected Terminal(String lineSeparator) {
        this.lineSeparator = lineSeparator;
    }

    /** Sets the {@link Terminal#verbosity} of the terminal. (Default is {@link Verbosity#NORMAL})
     * @param verbosity the {@link Verbosity} level that will be used for printing
     * */
    public void setVerbosity(Verbosity verbosity) {
        this.verbosity = verbosity;
    }

    /** Reads clear text from the terminal input.
     * @see Console#readLine()
     * @param prompt message to display to the user
     * @return the text entered by the user
     * */
    public abstract String readText(String prompt);

    /** Reads secret text from the terminal input with echoing disabled.
     * @see Console#readPassword()
     * @param prompt message to display to the user
     * @return the secret as a character array
     * */
    public abstract char[] readSecret(String prompt);

    /** Read secret text from terminal input with echoing disabled, up to a maximum length.
     * @see Console#readPassword()
     * @param prompt message to display to the user
     * @param maxLength the maximum length of the secret
     * @return the secret as a character array
     * @throws IllegalStateException if the secret exceeds the maximum length
     * */
    public char[] readSecret(String prompt, int maxLength) {
        char[] result = readSecret(prompt);
        if (result.length > maxLength) {
            Arrays.fill(result, '\0');
            throw new IllegalStateException("Secret exceeded maximum length of " + maxLength);
        }
        return result;
    }

    /** Returns a Writer which can be used to write to the terminal directly using standard output.
     * @return a writer to {@link Terminal#DEFAULT} output
     * @see Terminal.ConsoleTerminal
     * @see Terminal.SystemTerminal
     * */
    public abstract PrintWriter getWriter();

    /** Returns a Writer which can be used to write to the terminal directly using standard error.
     * @return a writer to stderr
     * */
    public PrintWriter getErrorWriter() {
        return ERROR_WRITER;
    }

    /** Prints a line to the terminal at {@link Verbosity#NORMAL} verbosity level, with a {@link Terminal#lineSeparator}
     * @param msg the message to print
     * */
    public final void println(String msg) {
        println(Verbosity.NORMAL, msg);
    }

    /** Prints message to the terminal's standard output at {@link Verbosity} level, with a {@link Terminal#lineSeparator}.
     * @param verbosity the {@link Verbosity} level at which to print
     * @param msg the message to print
     * */
    public final void println(Verbosity verbosity, String msg) {
        print(verbosity, msg + lineSeparator);
    }

    /** Prints message to the terminal's standard output at {@link Verbosity} level, without adding a {@link Terminal#lineSeparator}.
     * @param verbosity the {@link Verbosity} level at which to print
     * @param msg the message to print
     * */
    public final void print(Verbosity verbosity, String msg) {
        print(verbosity, msg, false);
    }

    /** Prints message to either standard or error output at {@link Verbosity} level, without adding a {@link Terminal#lineSeparator}.
     * @param verbosity the {@link Verbosity} level at which to print.
     * @param msg the message to print
     * @param isError if true, prints to standard error instead of standard output
     * */
    private void print(Verbosity verbosity, String msg, boolean isError) {
        if (isPrintable(verbosity)) {
            PrintWriter writer = isError ? getErrorWriter() : getWriter();
            writer.print(msg);
            writer.flush();
        }
    }

    /** Prints a line to the terminal's standard error at {@link Verbosity} level, without adding a {@link Terminal#lineSeparator}.
     * @param verbosity the {@link Verbosity} level at which to print.
     * @param msg the message to print
     * */
    public final void errorPrint(Verbosity verbosity, String msg) {
        print(verbosity, msg, true);
    }

    /** Prints a line to the terminal's standard error at {@link Verbosity#NORMAL} verbosity level, with a {@link Terminal#lineSeparator}
     * @param msg the message to print
     * */
    public final void errorPrintln(String msg) {
        errorPrintln(Verbosity.NORMAL, msg);
    }

    /** Prints a line to the terminal's standard error at {@link Verbosity} level, with a {@link Terminal#lineSeparator}.
     * @param verbosity the {@link Verbosity} level at which to print.
     * @param msg the message to print
     * */
    public final void errorPrintln(Verbosity verbosity, String msg) {
        errorPrint(verbosity, msg + lineSeparator);
    }

    /** Checks if given {@link Verbosity} level is high enough to be printed at the level defined by {@link Terminal#verbosity}
     * @param verbosity the {@link Verbosity} level to check
     * @return true if the {@link Verbosity} level is high enough to be printed
     * @see Terminal#setVerbosity(Verbosity)
     * */
    public final boolean isPrintable(Verbosity verbosity) {
        return this.verbosity.ordinal() >= verbosity.ordinal();
    }

    /**
     * Prompt for a yes or no answer from the user. This method will loop until 'y', 'n'
     * (or the default empty value) is entered.
     * @param prompt the prompt to display to the user
     * @param defaultYes if true, the default answer is 'y', otherwise it is 'n'
     * @return true if the user answered 'y', false if the user answered 'n' or the defaultYes value if the user entered nothing
     */
    public final boolean promptYesNo(String prompt, boolean defaultYes) {
        String answerPrompt = defaultYes ? " [Y/n]" : " [y/N]";
        while (true) {
            String answer = readText(prompt + answerPrompt);
            if (answer == null || answer.isEmpty()) {
                return defaultYes;
            }
            answer = answer.toLowerCase(Locale.ROOT);
            boolean answerYes = answer.equals("y");
            if (answerYes == false && answer.equals("n") == false) {
                errorPrintln("Did not understand answer '" + answer + "'");
                continue;
            }
            return answerYes;
        }
    }

    /**
     * Read from the reader until we find a newline. If that newline
     * character is immediately preceded by a carriage return, we have
     * a Windows-style newline, so we discard the carriage return as well
     * as the newline.
     * @param reader the reader to read from
     * @param maxLength the maximum length of the line to read
     * @return the line read from the reader
     * @throws RuntimeException if the line read exceeds the maximum length
     * @throws RuntimeException if an IOException occurs while reading
     */
    public static char[] readLineToCharArray(Reader reader, int maxLength) {
        char[] buf = new char[maxLength + 2];
        try {
            int len = 0;
            int next;
            while ((next = reader.read()) != -1) {
                char nextChar = (char) next;
                if (nextChar == '\n') {
                    break;
                }
                if (len < buf.length) {
                    buf[len] = nextChar;
                }
                len++;
            }

            if (len > 0 && len < buf.length && buf[len - 1] == '\r') {
                len--;
            }

            if (len > maxLength) {
                Arrays.fill(buf, '\0');
                throw new RuntimeException("Input exceeded maximum length of " + maxLength);
            }

            char[] shortResult = Arrays.copyOf(buf, len);
            Arrays.fill(buf, '\0');
            return shortResult;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /** Flushes the terminal's standard output and standard error. */
    public void flush() {
        this.getWriter().flush();
        this.getErrorWriter().flush();
    }

    private static class ConsoleTerminal extends Terminal {

        private static final Console CONSOLE = System.console();

        ConsoleTerminal() {
            super(System.lineSeparator());
        }

        static boolean isSupported() {
            return CONSOLE != null;
        }

        @Override
        public PrintWriter getWriter() {
            return CONSOLE.writer();
        }

        @Override
        public String readText(String prompt) {
            return CONSOLE.readLine("%s", prompt);
        }

        @Override
        public char[] readSecret(String prompt) {
            return CONSOLE.readPassword("%s", prompt);
        }
    }

    /** visible for testing */
    static class SystemTerminal extends Terminal {

        private static final PrintWriter WRITER = newWriter();

        private BufferedReader reader;

        SystemTerminal() {
            super(System.lineSeparator());
        }

        @SuppressForbidden(reason = "Writer for System.out")
        private static PrintWriter newWriter() {
            return new PrintWriter(System.out);
        }

        /** visible for testing */
        BufferedReader getReader() {
            if (reader == null) {
                reader = new BufferedReader(new InputStreamReader(System.in, Charset.defaultCharset()));
            }
            return reader;
        }

        @Override
        public PrintWriter getWriter() {
            return WRITER;
        }

        @Override
        public String readText(String text) {
            getErrorWriter().print(text); // prompts should go to standard error to avoid mixing with list output
            try {
                final String line = getReader().readLine();
                if (line == null) {
                    throw new IllegalStateException("unable to read from standard input; is standard input open and a tty attached?");
                }
                return line;
            } catch (IOException ioe) {
                throw new RuntimeException(ioe);
            }
        }

        @Override
        public char[] readSecret(String text) {
            return readText(text).toCharArray();
        }

        @Override
        public char[] readSecret(String text, int maxLength) {
            getErrorWriter().println(text);
            return readLineToCharArray(getReader(), maxLength);
        }
    }
}
