package tview

import (
	"fmt"
	"strings"

	"github.com/gdamore/tcell/v2"
)

// listItem represents one item in a List.
type listItem struct {
	MainText      string // The main text of the list item.
	SecondaryText string // A secondary text to be shown underneath the main text.
	Shortcut      rune   // The key to select the list item directly, 0 if there is no shortcut.
	Selected      func() // The optional function which is called when the item is selected.
}

// List displays rows of items, each of which can be selected. List items can be
// shown as a single line or as two lines. They can be selected by pressing
// their assigned shortcut key, navigating to them and pressing Enter, or
// clicking on them with the mouse. The following key binds are available:
//
//   - Down arrow / tab: Move down one item.
//   - Up arrow / backtab: Move up one item.
//   - Home: Move to the first item.
//   - End: Move to the last item.
//   - Page down: Move down one page.
//   - Page up: Move up one page.
//   - Enter / Space: Select the current item.
//   - Right / left: Scroll horizontally. Only if the list is wider than the
//     available space.
//
// See [List.SetChangedFunc] for a way to be notified when the user navigates
// to a list item. See [List.SetSelectedFunc] for a way to be notified when a
// list item was selected.
//
// See https://github.com/rivo/tview/wiki/List for an example.
type List struct {
	*Box

	// The Items of the list.
	Items []*listItem

	// The index of the currently selected item.
	CurrentItem int

	// Whether or not to show the secondary item texts.
	ShowSecondaryText bool

	// The item main text style.
	MainTextStyle tcell.Style

	// The item secondary text style.
	SecondaryTextStyle tcell.Style

	// The item shortcut text style.
	ShortcutStyle tcell.Style

	// The style for selected items.
	SelectedStyle tcell.Style

	// If true, the selection is only shown when the list has focus.
	SelectedFocusOnly bool

	// If true, the entire row is highlighted when selected.
	HighlightFullLine bool

	// Whether or not navigating the list will wrap around.
	WrapAround bool

	// The number of list items skipped at the top before the first item is
	// drawn.
	ItemOffset int

	// The number of cells skipped on the left side of an item text. Shortcuts
	// are not affected.
	HorizontalOffset int

	// Set to true if a currently visible item flows over the right border of
	// the box. This is set by the Draw() function. It determines the behaviour
	// of the right arrow key.
	Overflowing bool

	// An optional function which is called when the user has navigated to a
	// list item.
	Changed func(index int, mainText, secondaryText string, shortcut rune)

	// An optional function which is called when a list item was Selected. This
	// function will be called even if the list item defines its own callback.
	Selected func(index int, mainText, secondaryText string, shortcut rune)

	// An optional function which is called when the user presses the Escape key.
	Done func()
}

// NewList returns a new list.
func NewList() *List {
	return &List{
		Box:                NewBox(),
		ShowSecondaryText:  true,
		WrapAround:         true,
		MainTextStyle:      tcell.StyleDefault.Foreground(Styles.PrimaryTextColor),
		SecondaryTextStyle: tcell.StyleDefault.Foreground(Styles.TertiaryTextColor),
		ShortcutStyle:      tcell.StyleDefault.Foreground(Styles.SecondaryTextColor),
		SelectedStyle:      tcell.StyleDefault.Foreground(Styles.PrimitiveBackgroundColor).Background(Styles.PrimaryTextColor),
	}
}

// SetCurrentItem sets the currently selected item by its index, starting at 0
// for the first item. If a negative index is provided, items are referred to
// from the back (-1 = last item, -2 = second-to-last item, and so on). Out of
// range indices are clamped to the beginning/end.
//
// Calling this function triggers a "changed" event if the selection changes.
func (l *List) SetCurrentItem(index int) *List {
	if index < 0 {
		index = len(l.Items) + index
	}
	if index >= len(l.Items) {
		index = len(l.Items) - 1
	}
	if index < 0 {
		index = 0
	}

	if index != l.CurrentItem && l.Changed != nil {
		item := l.Items[index]
		l.Changed(index, item.MainText, item.SecondaryText, item.Shortcut)
	}

	l.CurrentItem = index

	l.adjustOffset()

	return l
}

// GetCurrentItem returns the index of the currently selected list item,
// starting at 0 for the first item.
func (l *List) GetCurrentItem() int {
	return l.CurrentItem
}

// SetOffset sets the number of items to be skipped (vertically) as well as the
// number of cells skipped horizontally when the list is drawn. Note that one
// item corresponds to two rows when there are secondary texts. Shortcuts are
// always drawn.
//
// These values may change when the list is drawn to ensure the currently
// selected item is visible and item texts move out of view. Users can also
// modify these values by interacting with the list.
func (l *List) SetOffset(items, horizontal int) *List {
	l.ItemOffset = items
	l.HorizontalOffset = horizontal
	return l
}

// GetOffset returns the number of items skipped while drawing, as well as the
// number of cells item text is moved to the left. See also SetOffset() for more
// information on these values.
func (l *List) GetOffset() (int, int) {
	return l.ItemOffset, l.HorizontalOffset
}

// RemoveItem removes the item with the given index (starting at 0) from the
// list. If a negative index is provided, items are referred to from the back
// (-1 = last item, -2 = second-to-last item, and so on). Out of range indices
// are clamped to the beginning/end, i.e. unless the list is empty, an item is
// always removed.
//
// The currently selected item is shifted accordingly. If it is the one that is
// removed, a "changed" event is fired, unless no items are left.
func (l *List) RemoveItem(index int) *List {
	if len(l.Items) == 0 {
		return l
	}

	// Adjust index.
	if index < 0 {
		index = len(l.Items) + index
	}
	if index >= len(l.Items) {
		index = len(l.Items) - 1
	}
	if index < 0 {
		index = 0
	}

	// Remove item.
	l.Items = append(l.Items[:index], l.Items[index+1:]...)

	// If there is nothing left, we're done.
	if len(l.Items) == 0 {
		return l
	}

	// Shift current item.
	previousCurrentItem := l.CurrentItem
	if l.CurrentItem > index || l.CurrentItem == len(l.Items) {
		l.CurrentItem--
	}

	// Fire "changed" event for removed items.
	if previousCurrentItem == index && l.Changed != nil {
		item := l.Items[l.CurrentItem]
		l.Changed(l.CurrentItem, item.MainText, item.SecondaryText, item.Shortcut)
	}

	return l
}

// SetMainTextColor sets the color of the items' main text.
func (l *List) SetMainTextColor(color tcell.Color) *List {
	l.MainTextStyle = l.MainTextStyle.Foreground(color)
	return l
}

// SetMainTextStyle sets the style of the items' main text. Note that the
// background color is ignored in order not to override the background color of
// the list itself.
func (l *List) SetMainTextStyle(style tcell.Style) *List {
	l.MainTextStyle = style
	return l
}

// SetSecondaryTextColor sets the color of the items' secondary text.
func (l *List) SetSecondaryTextColor(color tcell.Color) *List {
	l.SecondaryTextStyle = l.SecondaryTextStyle.Foreground(color)
	return l
}

// SetSecondaryTextStyle sets the style of the items' secondary text. Note that
// the background color is ignored in order not to override the background color
// of the list itself.
func (l *List) SetSecondaryTextStyle(style tcell.Style) *List {
	l.SecondaryTextStyle = style
	return l
}

// SetShortcutColor sets the color of the items' shortcut.
func (l *List) SetShortcutColor(color tcell.Color) *List {
	l.ShortcutStyle = l.ShortcutStyle.Foreground(color)
	return l
}

// SetShortcutStyle sets the style of the items' shortcut. Note that the
// background color is ignored in order not to override the background color of
// the list itself.
func (l *List) SetShortcutStyle(style tcell.Style) *List {
	l.ShortcutStyle = style
	return l
}

// SetSelectedTextColor sets the text color of selected items. Note that the
// color of main text characters that are different from the main text color
// (e.g. style tags) is maintained.
func (l *List) SetSelectedTextColor(color tcell.Color) *List {
	l.SelectedStyle = l.SelectedStyle.Foreground(color)
	return l
}

// SetSelectedBackgroundColor sets the background color of selected items.
func (l *List) SetSelectedBackgroundColor(color tcell.Color) *List {
	l.SelectedStyle = l.SelectedStyle.Background(color)
	return l
}

// SetSelectedStyle sets the style of the selected items. Note that the color of
// main text characters that are different from the main text color (e.g. color
// tags) is maintained.
func (l *List) SetSelectedStyle(style tcell.Style) *List {
	l.SelectedStyle = style
	return l
}

// SetSelectedFocusOnly sets a flag which determines when the currently selected
// list item is highlighted. If set to true, selected items are only highlighted
// when the list has focus. If set to false, they are always highlighted.
func (l *List) SetSelectedFocusOnly(focusOnly bool) *List {
	l.SelectedFocusOnly = focusOnly
	return l
}

// SetHighlightFullLine sets a flag which determines whether the colored
// background of selected items spans the entire width of the view. If set to
// true, the highlight spans the entire view. If set to false, only the text of
// the selected item from beginning to end is highlighted.
func (l *List) SetHighlightFullLine(highlight bool) *List {
	l.HighlightFullLine = highlight
	return l
}

// ShowSecondaryText determines whether or not to show secondary item texts.
func (l *List) SetShowSecondaryText(show bool) *List {
	l.ShowSecondaryText = show
	return l
}

// SetWrapAround sets the flag that determines whether navigating the list will
// wrap around. That is, navigating downwards on the last item will move the
// selection to the first item (similarly in the other direction). If set to
// false, the selection won't change when navigating downwards on the last item
// or navigating upwards on the first item.
func (l *List) SetWrapAround(wrapAround bool) *List {
	l.WrapAround = wrapAround
	return l
}

// SetChangedFunc sets the function which is called when the user navigates to
// a list item. The function receives the item's index in the list of items
// (starting with 0), its main text, secondary text, and its shortcut rune.
//
// This function is also called when the first item is added or when
// SetCurrentItem() is called.
func (l *List) SetChangedFunc(handler func(index int, mainText string, secondaryText string, shortcut rune)) *List {
	l.Changed = handler
	return l
}

// SetSelectedFunc sets the function which is called when the user selects a
// list item by pressing Enter on the current selection. The function receives
// the item's index in the list of items (starting with 0), its main text,
// secondary text, and its shortcut rune.
func (l *List) SetSelectedFunc(handler func(int, string, string, rune)) *List {
	l.Selected = handler
	return l
}

// SetDoneFunc sets a function which is called when the user presses the Escape
// key.
func (l *List) SetDoneFunc(handler func()) *List {
	l.Done = handler
	return l
}

// AddItem calls InsertItem() with an index of -1.
func (l *List) AddItem(mainText, secondaryText string, shortcut rune, selected func()) *List {
	l.InsertItem(-1, mainText, secondaryText, shortcut, selected)
	return l
}

// InsertItem adds a new item to the list at the specified index. An index of 0
// will insert the item at the beginning, an index of 1 before the second item,
// and so on. An index of GetItemCount() or higher will insert the item at the
// end of the list. Negative indices are also allowed: An index of -1 will
// insert the item at the end of the list, an index of -2 before the last item,
// and so on. An index of -GetItemCount()-1 or lower will insert the item at the
// beginning.
//
// An item has a main text which will be highlighted when selected. It also has
// a secondary text which is shown underneath the main text (if it is set to
// visible) but which may remain empty.
//
// The shortcut is a key binding. If the specified rune is entered, the item
// is selected immediately. Set to 0 for no binding.
//
// The "selected" callback will be invoked when the user selects the item. You
// may provide nil if no such callback is needed or if all events are handled
// through the selected callback set with SetSelectedFunc().
//
// The currently selected item will shift its position accordingly. If the list
// was previously empty, a "changed" event is fired because the new item becomes
// selected.
func (l *List) InsertItem(index int, mainText, secondaryText string, shortcut rune, selected func()) *List {
	item := &listItem{
		MainText:      mainText,
		SecondaryText: secondaryText,
		Shortcut:      shortcut,
		Selected:      selected,
	}

	// Shift index to range.
	if index < 0 {
		index = len(l.Items) + index + 1
	}
	if index < 0 {
		index = 0
	} else if index > len(l.Items) {
		index = len(l.Items)
	}

	// Shift current item.
	if l.CurrentItem < len(l.Items) && l.CurrentItem >= index {
		l.CurrentItem++
	}

	// Insert item (make space for the new item, then shift and insert).
	l.Items = append(l.Items, nil)
	if index < len(l.Items)-1 { // -1 because l.items has already grown by one item.
		copy(l.Items[index+1:], l.Items[index:])
	}
	l.Items[index] = item

	// Fire a "change" event for the first item in the list.
	if len(l.Items) == 1 && l.Changed != nil {
		item := l.Items[0]
		l.Changed(0, item.MainText, item.SecondaryText, item.Shortcut)
	}

	return l
}

// GetItemCount returns the number of items in the list.
func (l *List) GetItemCount() int {
	return len(l.Items)
}

// GetItemText returns an item's texts (main and secondary). Panics if the index
// is out of range.
func (l *List) GetItemText(index int) (main, secondary string) {
	return l.Items[index].MainText, l.Items[index].SecondaryText
}

// SetItemText sets an item's main and secondary text. Panics if the index is
// out of range.
func (l *List) SetItemText(index int, main, secondary string) *List {
	item := l.Items[index]
	item.MainText = main
	item.SecondaryText = secondary
	return l
}

// FindItems searches the main and secondary texts for the given strings and
// returns a list of item indices in which those strings are found. One of the
// two search strings may be empty, it will then be ignored. Indices are always
// returned in ascending order.
//
// If mustContainBoth is set to true, mainSearch must be contained in the main
// text AND secondarySearch must be contained in the secondary text. If it is
// false, only one of the two search strings must be contained.
//
// Set ignoreCase to true for case-insensitive search.
func (l *List) FindItems(mainSearch, secondarySearch string, mustContainBoth, ignoreCase bool) (indices []int) {
	if mainSearch == "" && secondarySearch == "" {
		return
	}

	if ignoreCase {
		mainSearch = strings.ToLower(mainSearch)
		secondarySearch = strings.ToLower(secondarySearch)
	}

	for index, item := range l.Items {
		mainText := item.MainText
		secondaryText := item.SecondaryText
		if ignoreCase {
			mainText = strings.ToLower(mainText)
			secondaryText = strings.ToLower(secondaryText)
		}

		// strings.Contains() always returns true for a "" search.
		mainContained := strings.Contains(mainText, mainSearch)
		secondaryContained := strings.Contains(secondaryText, secondarySearch)
		if mustContainBoth && mainContained && secondaryContained ||
			!mustContainBoth && (mainText != "" && mainContained || secondaryText != "" && secondaryContained) {
			indices = append(indices, index)
		}
	}

	return
}

// Clear removes all items from the list.
func (l *List) Clear() *List {
	l.Items = nil
	l.CurrentItem = 0
	return l
}

// Draw draws this primitive onto the screen.
func (l *List) Draw(screen tcell.Screen) {
	l.Box.DrawForSubclass(screen, l)

	// Determine the dimensions.
	x, y, width, height := l.GetInnerRect()
	bottomLimit := y + height
	_, totalHeight := screen.Size()
	if bottomLimit > totalHeight {
		bottomLimit = totalHeight
	}

	// Do we show any shortcuts?
	var showShortcuts bool
	for _, item := range l.Items {
		if item.Shortcut != 0 {
			showShortcuts = true
			x += 4
			width -= 4
			break
		}
	}

	if l.HorizontalOffset < 0 {
		l.HorizontalOffset = 0
	}

	// Draw the list items.
	var (
		maxWidth    int  // The maximum printed item width.
		overflowing bool // Whether a text's end exceeds the right border.
	)
	for index, item := range l.Items {
		if index < l.ItemOffset {
			continue
		}

		if y >= bottomLimit {
			break
		}

		// Shortcuts.
		if showShortcuts && item.Shortcut != 0 {
			printWithStyle(screen, fmt.Sprintf("(%s)", string(item.Shortcut)), x-5, y, 0, 4, AlignRight, l.ShortcutStyle, true)
		}

		// Main text.
		_, end, printedWidth := printWithStyle(screen, item.MainText, x, y, l.HorizontalOffset, width, AlignLeft, l.MainTextStyle, true)
		if printedWidth > maxWidth {
			maxWidth = printedWidth
		}
		if end < len(item.MainText) {
			overflowing = true
		}

		// Background color of selected text.
		if index == l.CurrentItem && (!l.SelectedFocusOnly || l.HasFocus()) {
			textWidth := width
			if !l.HighlightFullLine {
				if w := TaggedStringWidth(item.MainText); w < textWidth {
					textWidth = w
				}
			}

			mainTextColor, _, _ := l.MainTextStyle.Decompose()
			for bx := 0; bx < textWidth; bx++ {
				m, c, style, _ := screen.GetContent(x+bx, y)
				fg, _, _ := style.Decompose()
				style = l.SelectedStyle
				if fg != mainTextColor {
					style = style.Foreground(fg)
				}
				screen.SetContent(x+bx, y, m, c, style)
			}
		}

		y++

		if y >= bottomLimit {
			break
		}

		// Secondary text.
		if l.ShowSecondaryText {
			_, end, printedWidth := printWithStyle(screen, item.SecondaryText, x, y, l.HorizontalOffset, width, AlignLeft, l.SecondaryTextStyle, true)
			if printedWidth > maxWidth {
				maxWidth = printedWidth
			}
			if end < len(item.SecondaryText) {
				overflowing = true
			}
			y++
		}
	}

	// We don't want the item text to get out of view. If the horizontal offset
	// is too high, we reset it and redraw. (That should be about as efficient
	// as calculating everything up front.)
	if l.HorizontalOffset > 0 && maxWidth < width {
		l.HorizontalOffset -= width - maxWidth
		l.Draw(screen)
	}
	l.Overflowing = overflowing
}

// adjustOffset adjusts the vertical offset to keep the current selection in
// view.
func (l *List) adjustOffset() {
	_, _, _, height := l.GetInnerRect()
	if height == 0 {
		return
	}
	if l.CurrentItem < l.ItemOffset {
		l.ItemOffset = l.CurrentItem
	} else if l.ShowSecondaryText {
		if 2*(l.CurrentItem-l.ItemOffset) >= height-1 {
			l.ItemOffset = (2*l.CurrentItem + 3 - height) / 2
		}
	} else {
		if l.CurrentItem-l.ItemOffset >= height {
			l.ItemOffset = l.CurrentItem + 1 - height
		}
	}
}

// InputHandler returns the handler for this primitive.
func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
	return l.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
		if event.Key() == tcell.KeyEscape {
			if l.Done != nil {
				l.Done()
			}
			return
		} else if len(l.Items) == 0 {
			return
		}

		previousItem := l.CurrentItem

		switch key := event.Key(); key {
		case tcell.KeyTab, tcell.KeyDown:
			l.CurrentItem++
		case tcell.KeyBacktab, tcell.KeyUp:
			l.CurrentItem--
		case tcell.KeyRight:
			if l.Overflowing {
				l.HorizontalOffset += 2 // We shift by 2 to account for two-cell characters.
			} else {
				l.CurrentItem++
			}
		case tcell.KeyLeft:
			if l.HorizontalOffset > 0 {
				l.HorizontalOffset -= 2
			} else {
				l.CurrentItem--
			}
		case tcell.KeyHome:
			l.CurrentItem = 0
		case tcell.KeyEnd:
			l.CurrentItem = len(l.Items) - 1
		case tcell.KeyPgDn:
			_, _, _, height := l.GetInnerRect()
			l.CurrentItem += height
			if l.CurrentItem >= len(l.Items) {
				l.CurrentItem = len(l.Items) - 1
			}
		case tcell.KeyPgUp:
			_, _, _, height := l.GetInnerRect()
			l.CurrentItem -= height
			if l.CurrentItem < 0 {
				l.CurrentItem = 0
			}
		case tcell.KeyEnter:
			if l.CurrentItem >= 0 && l.CurrentItem < len(l.Items) {
				item := l.Items[l.CurrentItem]
				if item.Selected != nil {
					item.Selected()
				}
				if l.Selected != nil {
					l.Selected(l.CurrentItem, item.MainText, item.SecondaryText, item.Shortcut)
				}
			}
		case tcell.KeyRune:
			ch := event.Rune()
			if ch != ' ' {
				// It's not a space bar. Is it a shortcut?
				var found bool
				for index, item := range l.Items {
					if item.Shortcut == ch {
						// We have a shortcut.
						found = true
						l.CurrentItem = index
						break
					}
				}
				if !found {
					break
				}
			}
			item := l.Items[l.CurrentItem]
			if item.Selected != nil {
				item.Selected()
			}
			if l.Selected != nil {
				l.Selected(l.CurrentItem, item.MainText, item.SecondaryText, item.Shortcut)
			}
		}

		if l.CurrentItem < 0 {
			if l.WrapAround {
				l.CurrentItem = len(l.Items) - 1
			} else {
				l.CurrentItem = 0
			}
		} else if l.CurrentItem >= len(l.Items) {
			if l.WrapAround {
				l.CurrentItem = 0
			} else {
				l.CurrentItem = len(l.Items) - 1
			}
		}

		if l.CurrentItem != previousItem && l.CurrentItem < len(l.Items) {
			if l.Changed != nil {
				item := l.Items[l.CurrentItem]
				l.Changed(l.CurrentItem, item.MainText, item.SecondaryText, item.Shortcut)
			}
			l.adjustOffset()
		}
	})
}

// indexAtPoint returns the index of the list item found at the given position
// or a negative value if there is no such list item.
func (l *List) indexAtPoint(x, y int) int {
	rectX, rectY, width, height := l.GetInnerRect()
	if rectX < 0 || rectX >= rectX+width || y < rectY || y >= rectY+height {
		return -1
	}

	index := y - rectY
	if l.ShowSecondaryText {
		index /= 2
	}
	index += l.ItemOffset

	if index >= len(l.Items) {
		return -1
	}
	return index
}

// MouseHandler returns the mouse handler for this primitive.
func (l *List) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
	return l.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) {
		if !l.InRect(event.Position()) {
			return false, nil
		}

		// Process mouse event.
		switch action {
		case MouseLeftClick:
			setFocus(l)
			index := l.indexAtPoint(event.Position())
			if index != -1 {
				item := l.Items[index]
				if item.Selected != nil {
					item.Selected()
				}
				if l.Selected != nil {
					l.Selected(index, item.MainText, item.SecondaryText, item.Shortcut)
				}
				if index != l.CurrentItem {
					if l.Changed != nil {
						l.Changed(index, item.MainText, item.SecondaryText, item.Shortcut)
					}
					l.adjustOffset()
				}
				l.CurrentItem = index
			}
			consumed = true
		case MouseScrollUp:
			if l.ItemOffset > 0 {
				l.ItemOffset--
			}
			consumed = true
		case MouseScrollDown:
			lines := len(l.Items) - l.ItemOffset
			if l.ShowSecondaryText {
				lines *= 2
			}
			if _, _, _, height := l.GetInnerRect(); lines > height {
				l.ItemOffset++
			}
			consumed = true
		}

		return
	})
}
