// Copyright 2016-2018, Pulumi Corporation.
//
// Licensed 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.

package cmdutil

import (
	"fmt"
	"io"
	"os"
	"regexp"
	"runtime"
	"strings"

	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/rivo/uniseg"
	"golang.org/x/term"

	"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
	"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/ciutil"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
)

// Emoji controls whether emojis will by default be printed in the output.
// While some Linux systems can display Emoji's in the terminal by default, we restrict this to just macOS, like Yarn.
var Emoji = (runtime.GOOS == "darwin")

// EmojiOr returns the emoji string e if emojis are enabled, or the string or if emojis are disabled.
func EmojiOr(e, or string) string {
	if Emoji && Interactive() {
		return e
	}
	return or
}

// DisableInteractive may be set to true in order to disable prompts. This is useful when running in a non-attended
// scenario, such as in continuous integration, or when using the Pulumi CLI/SDK in a programmatic way.
var DisableInteractive bool

// Interactive returns true if we should be running in interactive mode. That is, we have an interactive terminal
// session, interactivity hasn't been explicitly disabled, and we're not running in a known CI system.
func Interactive() bool {
	return !DisableInteractive && InteractiveTerminal() && !ciutil.IsCI()
}

// InteractiveTerminal returns true if the current terminal session is interactive.
func InteractiveTerminal() bool {
	// If there's a 'TERM' variable and the terminal is 'dumb', then disable interactive mode.
	if v := strings.ToLower(os.Getenv("TERM")); v == "dumb" {
		return false
	}

	// if we're piping in stdin, we're clearly not interactive, as there's no way for a user to
	// provide input.  If we're piping stdout, we also can't be interactive as there's no way for
	// users to see prompts to interact with them.
	return term.IsTerminal(int(os.Stdin.Fd())) &&
		term.IsTerminal(int(os.Stdout.Fd()))
}

// ReadConsole reads the console with the given prompt text.
func ReadConsole(prompt string) (string, error) {
	if !term.IsTerminal(int(os.Stdin.Fd())) {
		return readConsolePlain(os.Stdout, os.Stdin, prompt)
	}

	return readConsoleFancy(os.Stdout, os.Stdin, prompt, false /* secret */)
}

// readConsolePlain prints the given prompt (if any),
// and reads the user's response from stdin.
//
// It does so without altering the terminal's state in any way,
// and will work even if stdin is not a terminal.
func readConsolePlain(stdout io.Writer, stdin io.Reader, prompt string) (string, error) {
	if prompt != "" {
		fmt.Print(prompt + ": ")
	}

	var raw strings.Builder
	for {
		var b [1]byte
		if _, err := os.Stdin.Read(b[:]); err != nil {
			return "", err
		}
		if b[0] == '\n' {
			break
		}
		raw.WriteByte(b[0])
	}
	return RemoveTrailingNewline(raw.String()), nil
}

func readConsoleFancy(stdout io.Writer, stdin io.Reader, prompt string, secret bool) (string, error) {
	final, err := tea.NewProgram(
		newReadConsoleModel(prompt, secret),
		tea.WithInput(stdin),
		tea.WithOutput(stdout),
	).Run()
	if err != nil {
		return "", err
	}

	model, ok := final.(readConsoleModel)
	contract.Assertf(ok, "expected readConsoleModel, got %T", final)
	if model.Canceled {
		return "", io.EOF
	}

	return model.Value, nil
}

// IsTruthy returns true if the given string represents a CLI input interpreted as "true".
func IsTruthy(s string) bool {
	return s == "1" || strings.EqualFold(s, "true")
}

// RemoveTrailingNewline removes a trailing newline from a string. On windows, we'll remove either \r\n or \n, on other
// platforms, we just remove \n.
func RemoveTrailingNewline(s string) string {
	s = strings.TrimSuffix(s, "\n")

	if runtime.GOOS == "windows" {
		s = strings.TrimSuffix(s, "\r")
	}

	return s
}

// EndKeypadTransmitMode switches the terminal out of the keypad transmit 'application' mode back to 'normal' mode.
func EndKeypadTransmitMode() {
	if runtime.GOOS != "windows" && Interactive() {
		// Print an escape sequence to switch the keypad mode, same as 'tput rmkx'.
		// Work around https://github.com/pulumi/pulumi/issues/3480.
		// A better fix might be fixing upstream https://github.com/AlecAivazis/survey/issues/228.
		fmt.Print("\033[?1l")
	}
}

type Table struct {
	Headers []string
	Rows    []TableRow // Rows of the table.
	Prefix  string     // Optional prefix to print before each row
}

// TableRow is a row in a table we want to print.  It can be a series of a columns, followed
// by an additional line of information.
type TableRow struct {
	Columns        []string // Columns of the row
	AdditionalInfo string   // an optional line of information to print after the row
}

// FprintTable prints a grid of rows and columns.  Width of columns is automatically determined by
// the max length of the items in each column.  A default gap of two spaces is printed between each
// column.
func FprintTable(w io.Writer, table Table) error {
	_, err := fmt.Fprint(w, table)
	return err
}

// PrintTable prints the table to stdout.
// See [FprintTable] for details.
func PrintTable(table Table) {
	_ = FprintTable(os.Stdout, table)
	// Ignore error for stdout.
}

// PrintTableWithGap prints a grid of rows and columns.  Width of columns is automatically determined
// by the max length of the items in each column.  A gap can be specified between the columns.
func PrintTableWithGap(table Table, columnGap string) {
	fmt.Print(table.ToStringWithGap(columnGap))
}

func (table Table) String() string {
	return table.ToStringWithGap("  ")
}

// 7-bit C1 ANSI sequences
var ansiEscape = regexp.MustCompile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`)

// MeasureText returns the number of glyphs in a string.
// Importantly this also ignores ANSI escape sequences, so can be used to calculate layout of colorized strings.
func MeasureText(text string) int {
	// Strip ansi escape sequences
	clean := ansiEscape.ReplaceAllString(text, "")
	// Need to count graphemes not runes or bytes
	return uniseg.StringWidth(clean)
}

// normalizedRows returns the rows of a table in normalized form.
//
// A row is considered normalized if and only if it has no new lines in any of its fields.
func (table Table) normalizedRows() []TableRow {
	rows := slice.Prealloc[TableRow](len(table.Rows))
	for _, row := range table.Rows {
		info := row.AdditionalInfo
		buckets := make([][]string, len(row.Columns))
		maxLines := 0
		for i, column := range row.Columns {
			buckets[i] = strings.Split(column, "\n")
			maxLines = max(maxLines, len(buckets[i]))
		}
		row := []TableRow{}
		for i := 0; i < maxLines; i++ {
			part := TableRow{}
			for _, b := range buckets {
				if i < len(b) {
					part.Columns = append(part.Columns, b[i])
				} else {
					part.Columns = append(part.Columns, "")
				}
			}
			row = append(row, part)
		}
		row[len(row)-1].AdditionalInfo = info
		rows = append(rows, row...)
	}
	return rows
}

func (table Table) ToStringWithGap(columnGap string) string {
	return table.Render(&TableRenderOptions{ColumnGap: columnGap})
}

type TableRenderOptions struct {
	ColumnGap   string
	HeaderStyle []colors.Color
	ColumnStyle []colors.Color
	Color       colors.Colorization
}

func (table Table) Render(opts *TableRenderOptions) string {
	if opts == nil {
		opts = &TableRenderOptions{}
	}
	if opts.ColumnGap == "" {
		opts.ColumnGap = "  "
	}
	if opts.Color == "" {
		opts.Color = colors.Never
	}

	columnCount := len(table.Headers)

	// Figure out the preferred column width for each column.  It will be set to the max length of
	// any item in that column.
	preferredColumnWidths := make([]int, columnCount)

	allRows := []TableRow{{
		Columns: table.Headers,
	}}

	allRows = append(allRows, table.normalizedRows()...)

	for rowIndex, row := range allRows {
		columns := row.Columns
		if len(columns) != len(preferredColumnWidths) {
			panic(fmt.Sprintf(
				"Error printing table.  Column count of row %v didn't match header column count. %v != %v",
				rowIndex, len(columns), len(preferredColumnWidths)))
		}

		for columnIndex, val := range columns {
			preferredColumnWidths[columnIndex] = max(preferredColumnWidths[columnIndex], MeasureText(val))
		}
	}

	var result strings.Builder
	for rowIndex, row := range allRows {
		result.WriteString(table.Prefix)

		for columnIndex, val := range row.Columns {
			style := opts.HeaderStyle
			if rowIndex != 0 {
				style = opts.ColumnStyle
			}

			if len(style) != 0 {
				result.WriteString(opts.Color.Colorize(style[columnIndex]))
			}

			result.WriteString(val)

			if len(style) != 0 {
				result.WriteString(opts.Color.Colorize(colors.Reset))
			}

			if columnIndex < columnCount-1 {
				// Work out how much whitespace we need to add to this string to bring it up to the
				// preferredColumnWidth for this column.

				maxWidth := preferredColumnWidths[columnIndex]
				padding := maxWidth - MeasureText(val)
				result.WriteString(strings.Repeat(" ", padding))

				// Now, ensure we have the requested gap between columns as well.
				result.WriteString(opts.ColumnGap)
			}
			// do not want whitespace appended to the last column.  It would cause wrapping on lines
			// that were not actually long if some other line was very long.
		}

		result.WriteByte('\n')

		if row.AdditionalInfo != "" {
			result.WriteString(row.AdditionalInfo)
		}
	}
	return result.String()
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

// readConsoleModel drives a bubbletea widget that reads from the console.
type readConsoleModel struct {
	input  textinput.Model
	secret bool

	// Canceled is set to true when the model finishes
	// if the user canceled the operation by pressing Ctrl-C or Esc.
	Canceled bool

	// Value is the user's response to the prompt.
	Value string
}

var _ tea.Model = readConsoleModel{}

func newReadConsoleModel(prompt string, secret bool) readConsoleModel {
	input := textinput.New()
	input.Cursor.Style = lipgloss.NewStyle().
		Foreground(lipgloss.Color("205")) // 205 = hot pink cursor
	if secret {
		input.EchoMode = textinput.EchoPassword
	}
	if prompt != "" {
		input.Prompt = prompt + ": "
	}
	input.Focus() // required to receive input

	return readConsoleModel{
		input:  input,
		secret: secret,
	}
}

// Init initializes the model.
// We don't have any initialization to do, so we just return nil.
func (readConsoleModel) Init() tea.Cmd { return nil }

// Update handles a single tick of the bubbletea loop.
func (m readConsoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		// If the user pressed enter, Ctrl-C, or Esc,
		// it's time to stop the bubbletea loop.
		//
		// Only Enter is considered a success.
		switch msg.Type {
		case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
			m.Value = m.input.Value()
			m.Canceled = msg.Type != tea.KeyEnter

			m.input.Blur() // hide the cursor
			if m.secret {
				// If we're in secret mode, don't include
				// the '*' characters in the final output
				// so as not to leak the length of the input.
				m.input.EchoMode = textinput.EchoNone
			}

			var cmds []tea.Cmd
			if !m.Canceled {
				// If the user accepts the input,
				// we'll primnt the prompt to the terminal
				// before exiting this loop.
				cmds = append(cmds, tea.Println(m.input.View()))
			}
			cmds = append(cmds, tea.Quit)

			return m, tea.Sequence(cmds...)
		}
	}

	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	return m, cmd
}

// View renders the prompt.
func (m readConsoleModel) View() string {
	return m.input.View()
}