// Package filter provides a fuzzy searching text input to allow filtering a
// list of options to select one option.
//
// By default it will list all the files (recursively) in the current directory
// for the user to choose one, but the script (or user) can provide different
// new-line separated options to choose from.
//
// I.e. let's pick from a list of gum flavors:
//
// $ cat flavors.text | gum filter
package filter

import (
	"strings"

	"github.com/charmbracelet/bubbles/textinput"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/mattn/go-runewidth"
	"github.com/sahilm/fuzzy"
)

type model struct {
	textinput             textinput.Model
	viewport              *viewport.Model
	choices               []string
	matches               []fuzzy.Match
	cursor                int
	selected              map[string]struct{}
	limit                 int
	numSelected           int
	indicator             string
	selectedPrefix        string
	unselectedPrefix      string
	height                int
	aborted               bool
	quitting              bool
	matchStyle            lipgloss.Style
	textStyle             lipgloss.Style
	indicatorStyle        lipgloss.Style
	selectedPrefixStyle   lipgloss.Style
	unselectedPrefixStyle lipgloss.Style
}

func (m model) Init() tea.Cmd { return nil }
func (m model) View() string {
	if m.quitting {
		return ""
	}

	var s strings.Builder

	// Since there are matches, display them so that the user can see, in real
	// time, what they are searching for.
	for i, match := range m.matches {
		// If this is the current selected index, we add a small indicator to
		// represent it. Otherwise, simply pad the string.
		if i == m.cursor {
			s.WriteString(m.indicatorStyle.Render(m.indicator))
		} else {
			s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator)))
		}

		// If there are multiple selections mark them, otherwise leave an empty space
		if _, ok := m.selected[match.Str]; ok {
			s.WriteString(m.selectedPrefixStyle.Render(m.selectedPrefix))
		} else if m.limit > 1 {
			s.WriteString(m.unselectedPrefixStyle.Render(m.unselectedPrefix))
		} else {
			s.WriteString(" ")
		}

		// For this match, there are a certain number of characters that have
		// caused the match. i.e. fuzzy matching.
		// We should indicate to the users which characters are being matched.
		var mi = 0
		for ci, c := range match.Str {
			// Check if the current character index matches the current matched
			// index. If so, color the character to indicate a match.
			if mi < len(match.MatchedIndexes) && ci == match.MatchedIndexes[mi] {
				s.WriteString(m.matchStyle.Render(string(c)))
				// We have matched this character, so we never have to check it
				// again. Move on to the next match.
				mi++
			} else {
				// Not a match, simply show the character, unstyled.
				s.WriteString(m.textStyle.Render(string(c)))
			}
		}

		// We have finished displaying the match with all of it's matched
		// characters highlighted and the rest filled in.
		// Move on to the next match.
		s.WriteRune('\n')
	}

	m.viewport.SetContent(s.String())

	// View the input and the filtered choices
	return m.textinput.View() + "\n" + m.viewport.View()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		if m.height == 0 || m.height > msg.Height {
			m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View())
		}
		m.viewport.Width = msg.Width
	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c", "esc":
			m.aborted = true
			m.quitting = true
			return m, tea.Quit
		case "enter":
			m.quitting = true
			return m, tea.Quit
		case "ctrl+n", "ctrl+j", "down":
			m.CursorDown()
		case "ctrl+p", "ctrl+k", "up":
			m.CursorUp()
		case "tab":
			if m.limit == 1 {
				break // no op
			}
			m.ToggleSelection()
			m.CursorDown()
		case "shift+tab":
			if m.limit == 1 {
				break // no op
			}
			m.ToggleSelection()
			m.CursorUp()
		default:
			m.textinput, cmd = m.textinput.Update(msg)

			// A character was entered, this likely means that the text input
			// has changed. This suggests that the matches are outdated, so
			// update them, with a fuzzy finding algorithm provided by
			// https://github.com/sahilm/fuzzy
			m.matches = fuzzy.Find(m.textinput.Value(), m.choices)

			// If the search field is empty, let's not display the matches
			// (none), but rather display all possible choices.
			if m.textinput.Value() == "" {
				m.matches = matchAll(m.choices)
			}
		}
	}

	// It's possible that filtering items have caused fewer matches. So, ensure
	// that the selected index is within the bounds of the number of matches.
	m.cursor = clamp(0, len(m.matches)-1, m.cursor)
	return m, cmd
}

func (m *model) CursorUp() {
	m.cursor = clamp(0, len(m.matches)-1, m.cursor-1)
	if m.cursor < m.viewport.YOffset {
		m.viewport.SetYOffset(m.cursor)
	}
}

func (m *model) CursorDown() {
	m.cursor = clamp(0, len(m.matches)-1, m.cursor+1)
	if m.cursor >= m.viewport.YOffset+m.viewport.Height {
		m.viewport.LineDown(1)
	}
}

func (m *model) ToggleSelection() {
	if _, ok := m.selected[m.matches[m.cursor].Str]; ok {
		delete(m.selected, m.matches[m.cursor].Str)
		m.numSelected--
	} else if m.numSelected < m.limit {
		m.selected[m.matches[m.cursor].Str] = struct{}{}
		m.numSelected++
	}
}

func matchAll(options []string) []fuzzy.Match {
	var matches = make([]fuzzy.Match, len(options))
	for i, option := range options {
		matches[i] = fuzzy.Match{Str: option}
	}
	return matches
}

//nolint:unparam
func clamp(min, max, val int) int {
	if val < min {
		return min
	}
	if val > max {
		return max
	}
	return val
}
