// Package picker provides a multi-select file picker component for
// Bubble Tea applications, backed by an [io/fs.FS].
//
// Vendored from charm.land/bubbles/v2/filepicker (upstream main branch,
// 2026-03-26) with the following modifications:
//
//   - Stripped to fs.FS only (no real-filesystem / os.ReadDir support)
//   - Multi-select only with tri-state directory checkboxes
//   - Single-select mode, AllowedTypes, and DidSelectFile removed
//   - Package renamed from filepicker to picker
//
// Upstream: https://github.com/charmbracelet/bubbles
// PR #759:  https://github.com/charmbracelet/bubbles/pull/759
package picker

import (
	"fmt"
	"io/fs"
	"path"
	"sort"
	"strconv"
	"strings"
	"sync/atomic"

	"charm.land/bubbles/v2/key"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/dustin/go-humanize"
)

var lastID int64

func nextID() int {
	return int(atomic.AddInt64(&lastID, 1))
}

// New returns a new multi-select file picker with default styling and
// key bindings. The Selection is built from the given FS.
func New(fsys fs.FS) Model {
	return Model{
		id:               nextID(),
		FS:               fsys,
		Selection:        NewSelection(fsys),
		CurrentDirectory: ".",
		Cursor:           ">",
		selected:         0,
		ShowPermissions:  true,
		ShowSize:         true,
		ShowHidden:       false,
		AutoHeight:       true,
		height:           0,
		maxIdx:           0,
		minIdx:           0,
		selectedStack:    newStack(),
		minStack:         newStack(),
		maxStack:         newStack(),
		KeyMap:           DefaultKeyMap(),
		Styles:           DefaultStyles(),
	}
}

type errorMsg struct {
	err error
}

type readDirMsg struct {
	id      int
	entries []fs.DirEntry
}

const (
	marginBottom  = 5
	fileSizeWidth = 7
	paddingLeft   = 2
)

// KeyMap defines key bindings for each user action.
type KeyMap struct {
	GoToTop  key.Binding
	GoToLast key.Binding
	Down     key.Binding
	Up       key.Binding
	PageUp   key.Binding
	PageDown key.Binding
	Back     key.Binding
	Open     key.Binding
	Select   key.Binding
	Toggle   key.Binding
	Quit     key.Binding
}

// DefaultKeyMap defines the default keybindings.
func DefaultKeyMap() KeyMap {
	return KeyMap{
		GoToTop:  key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")),
		GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")),
		Down:     key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")),
		Up:       key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")),
		PageUp:   key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")),
		PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")),
		Back:     key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
		Open:     key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("l", "open")),
		Select:   key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
		Toggle:   key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
		Quit:     key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel")),
	}
}

// Styles defines the possible customizations for styles in the file picker.
type Styles struct {
	Cursor         lipgloss.Style
	Directory      lipgloss.Style
	File           lipgloss.Style
	Permission     lipgloss.Style
	Selected       lipgloss.Style
	FileSize       lipgloss.Style
	EmptyDirectory lipgloss.Style
}

// DefaultStyles defines the default styling for the file picker.
func DefaultStyles() Styles {
	return Styles{
		Cursor:         lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
		Directory:      lipgloss.NewStyle().Foreground(lipgloss.Color("99")),
		File:           lipgloss.NewStyle(),
		Permission:     lipgloss.NewStyle().Foreground(lipgloss.Color("244")),
		Selected:       lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
		FileSize:       lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right),
		EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
	}
}

// Model represents a multi-select file picker backed by an [io/fs.FS].
// Use [New] to construct; the Selection field is always initialized.
type Model struct {
	id int

	// FS is the filesystem to browse.
	FS fs.FS

	// Selection tracks multi-select state. Always set by New;
	// renders tri-state checkboxes and supports space-to-toggle.
	Selection *Selection

	// Confirmed is set to true when the user presses Enter to confirm
	// the selection. The caller should check this after the program
	// exits and read SelectedPaths()/AllSelected() from the Selection.
	Confirmed bool

	// CurrentDirectory is the directory that the user is currently in.
	CurrentDirectory string

	KeyMap          KeyMap
	files           []fs.DirEntry
	ShowPermissions bool
	ShowSize        bool
	ShowHidden      bool

	selected      int
	selectedStack stack

	minIdx   int
	maxIdx   int
	maxStack stack
	minStack stack

	height     int
	AutoHeight bool

	Cursor string
	Styles Styles
}

type stack struct {
	Push   func(int)
	Pop    func() int
	Length func() int
}

func newStack() stack {
	slice := make([]int, 0)
	return stack{
		Push: func(i int) {
			slice = append(slice, i)
		},
		Pop: func() int {
			res := slice[len(slice)-1]
			slice = slice[:len(slice)-1]
			return res
		},
		Length: func() int {
			return len(slice)
		},
	}
}

func (m *Model) pushView(selected, minimum, maximum int) {
	m.selectedStack.Push(selected)
	m.minStack.Push(minimum)
	m.maxStack.Push(maximum)
}

func (m *Model) popView() (int, int, int) {
	return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
}

func (m Model) readDir(dir string, showHidden bool) tea.Cmd {
	return func() tea.Msg {
		dirEntries, err := fs.ReadDir(m.FS, dir)
		if err != nil {
			return errorMsg{err}
		}

		sort.Slice(dirEntries, func(i, j int) bool {
			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
				return dirEntries[i].Name() < dirEntries[j].Name()
			}
			return dirEntries[i].IsDir()
		})

		if showHidden {
			return readDirMsg{id: m.id, entries: dirEntries}
		}

		var filtered []fs.DirEntry
		for _, e := range dirEntries {
			if strings.HasPrefix(e.Name(), ".") {
				continue
			}
			filtered = append(filtered, e)
		}
		return readDirMsg{id: m.id, entries: filtered}
	}
}

// SetHeight sets the height of the file picker.
func (m *Model) SetHeight(h int) {
	m.height = h
	if m.maxIdx > m.height-1 {
		m.maxIdx = m.minIdx + m.height - 1
	}
}

// Height returns the height of the file picker.
func (m Model) Height() int {
	return m.height
}

// Init initializes the file picker model.
func (m Model) Init() tea.Cmd {
	return m.readDir(m.CurrentDirectory, m.ShowHidden)
}

// Update handles user interactions within the file picker model.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
	switch msg := msg.(type) {
	case readDirMsg:
		if msg.id != m.id {
			break
		}
		m.files = msg.entries
		m.maxIdx = max(m.maxIdx, m.Height()-1)
	case tea.WindowSizeMsg:
		if m.AutoHeight {
			m.SetHeight(msg.Height - marginBottom)
		}
		m.maxIdx = m.minIdx + m.Height() - 1
		if m.maxIdx >= len(m.files) && len(m.files) > 0 {
			m.maxIdx = len(m.files) - 1
			m.minIdx = max(0, m.maxIdx-m.Height()+1)
		}
		if m.selected > m.maxIdx {
			m.selected = m.maxIdx
		}
		if m.selected < m.minIdx {
			m.selected = m.minIdx
		}
	case tea.KeyPressMsg:
		switch {
		case key.Matches(msg, m.KeyMap.Quit):
			return m, tea.Quit
		case key.Matches(msg, m.KeyMap.GoToTop):
			m.selected = 0
			m.minIdx = 0
			m.maxIdx = m.Height() - 1
		case key.Matches(msg, m.KeyMap.GoToLast):
			m.selected = len(m.files) - 1
			m.minIdx = len(m.files) - m.Height()
			m.maxIdx = len(m.files) - 1
		case key.Matches(msg, m.KeyMap.Down):
			m.selected++
			if m.selected >= len(m.files) {
				m.selected = len(m.files) - 1
			}
			if m.selected > m.maxIdx {
				m.minIdx++
				m.maxIdx++
			}
		case key.Matches(msg, m.KeyMap.Up):
			m.selected--
			if m.selected < 0 {
				m.selected = 0
			}
			if m.selected < m.minIdx {
				m.minIdx--
				m.maxIdx--
			}
		case key.Matches(msg, m.KeyMap.PageDown):
			m.selected += m.Height()
			if m.selected >= len(m.files) {
				m.selected = len(m.files) - 1
			}
			m.minIdx += m.Height()
			m.maxIdx += m.Height()

			if m.maxIdx >= len(m.files) {
				m.maxIdx = len(m.files) - 1
				m.minIdx = m.maxIdx - m.Height()
			}
		case key.Matches(msg, m.KeyMap.PageUp):
			m.selected -= m.Height()
			if m.selected < 0 {
				m.selected = 0
			}
			m.minIdx -= m.Height()
			m.maxIdx -= m.Height()

			if m.minIdx < 0 {
				m.minIdx = 0
				m.maxIdx = m.minIdx + m.Height()
			}
		case key.Matches(msg, m.KeyMap.Toggle):
			if m.Selection != nil && len(m.files) > 0 {
				p := m.entryPath(m.files[m.selected].Name())
				m.Selection.Toggle(p)
			}
		case key.Matches(msg, m.KeyMap.Select):
			// Enter confirms the selection and quits.
			m.Confirmed = true
			return m, tea.Quit
		case key.Matches(msg, m.KeyMap.Back):
			m.CurrentDirectory = path.Dir(m.CurrentDirectory)
			if m.selectedStack.Length() > 0 {
				m.selected, m.minIdx, m.maxIdx = m.popView()
			} else {
				m.selected = 0
				m.minIdx = 0
				m.maxIdx = m.Height() - 1
			}
			return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
		case key.Matches(msg, m.KeyMap.Open):
			if len(m.files) == 0 {
				break
			}

			if !m.files[m.selected].IsDir() {
				break
			}

			m.CurrentDirectory = m.entryPath(m.files[m.selected].Name())
			m.pushView(m.selected, m.minIdx, m.maxIdx)
			m.selected = 0
			m.minIdx = 0
			m.maxIdx = m.Height() - 1
			return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
		}
	}
	return m, nil
}

// View returns the view of the file picker.
func (m Model) View() string {
	if len(m.files) == 0 {
		return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
	}
	var s strings.Builder

	for i, f := range m.files {
		if i < m.minIdx || i > m.maxIdx {
			continue
		}

		info, err := f.Info()
		if err != nil {
			continue
		}
		size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
		name := f.Name()

		if m.selected == i {
			selected := m.checkboxFor(f.Name())
			if m.ShowPermissions {
				selected += " " + info.Mode().String()
			}
			if m.ShowSize {
				selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
			}
			selected += " " + name
			s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
			s.WriteRune('\n')
			continue
		}

		style := m.Styles.File
		if f.IsDir() {
			style = m.Styles.Directory
		}

		fileName := style.Render(name)
		s.WriteString(m.Styles.Cursor.Render(" "))
		s.WriteString(m.checkboxFor(f.Name()))
		if m.ShowPermissions {
			s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
		}
		if m.ShowSize {
			s.WriteString(m.Styles.FileSize.Render(size))
		}
		s.WriteString(" " + fileName)
		s.WriteRune('\n')
	}

	for i := lipgloss.Height(s.String()); i <= m.Height(); i++ {
		s.WriteRune('\n')
	}

	return s.String()
}

// checkboxFor returns the tri-state checkbox prefix for the given
// entry name. Returns an empty string if Selection is nil (defensive;
// New always initializes it).
func (m Model) checkboxFor(name string) string {
	if m.Selection == nil {
		return ""
	}
	p := m.entryPath(name)
	switch m.Selection.State(p) {
	case CheckAll:
		return " [x]"
	case CheckPartial:
		return " [-]"
	default:
		return " [ ]"
	}
}

// entryPath returns the FS-relative path for a child entry name in
// the current directory.
func (m Model) entryPath(name string) string {
	return path.Join(m.CurrentDirectory, name)
}

// HighlightedPath returns the path of the currently highlighted file or directory.
func (m Model) HighlightedPath() string {
	if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
		return ""
	}
	return m.entryPath(m.files[m.selected].Name())
}
