package picker

import (
	"io/fs"
	"testing"

	tea "charm.land/bubbletea/v2"

	"git.secluded.site/keld/internal/restic"
)

// testNodes returns a small set of LsNodes for building a test FS.
// Tree structure:
//
//	music/
//	  album-01/
//	    01-track.flac
//	    02-track.flac
//	  album-02/
//	    01-song.flac
//	docs/
//	  notes.txt
//	readme.txt
func testNodes() []restic.LsNode {
	return []restic.LsNode{
		{Name: "music", Type: "dir", Path: "/music"},
		{Name: "album-01", Type: "dir", Path: "/music/album-01"},
		{Name: "01-track.flac", Type: "file", Path: "/music/album-01/01-track.flac", Size: 1000},
		{Name: "02-track.flac", Type: "file", Path: "/music/album-01/02-track.flac", Size: 2000},
		{Name: "album-02", Type: "dir", Path: "/music/album-02"},
		{Name: "01-song.flac", Type: "file", Path: "/music/album-02/01-song.flac", Size: 3000},
		{Name: "docs", Type: "dir", Path: "/docs"},
		{Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500},
		{Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100},
	}
}

// initPicker creates a picker Model from test nodes, sends a
// WindowSizeMsg to set the height, and processes the initial readDir
// command so the file list is populated and ready for key events.
func initPicker(t *testing.T) Model {
	t.Helper()

	sfs := restic.NewSnapshotFS(testNodes())
	m := New(sfs)

	// Process Init to get the readDir command.
	cmd := m.Init()
	if cmd == nil {
		t.Fatal("Init returned nil cmd")
	}
	msg := cmd()
	m, _ = m.Update(msg)

	// Set a reasonable height so the picker can display entries.
	m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})

	return m
}

// Key construction helpers. key.Matches compares KeyPressMsg.String()
// against the binding's key list, so we need String() to return the
// right value for each key.

func keyPress(code rune, text string) tea.KeyPressMsg {
	return tea.KeyPressMsg{Code: code, Text: text}
}

func keyEnter() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func keySpace() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} }
func keyCtrlC() tea.KeyPressMsg { return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} }
func keyL() tea.KeyPressMsg     { return keyPress('l', "l") }
func keyH() tea.KeyPressMsg     { return keyPress('h', "h") }
func keyJ() tea.KeyPressMsg     { return keyPress('j', "j") }
func keyK() tea.KeyPressMsg     { return keyPress('k', "k") }
func keyRight() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyRight} }

func TestFilePickerSpaceTogglesFile(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Root directory listing is sorted: dirs first, then files.
	// Expected order: docs/, music/, readme.txt
	// Navigate to readme.txt (index 2).
	m, _ = m.Update(keyJ()) // 0→1
	m, _ = m.Update(keyJ()) // 1→2

	if m.HighlightedPath() != "readme.txt" {
		t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath())
	}

	// Space should select it.
	m, _ = m.Update(keySpace())
	if m.Selection.State("readme.txt") != CheckAll {
		t.Errorf("after space: got %v, want CheckAll", m.Selection.State("readme.txt"))
	}

	// Space again should deselect it.
	m, _ = m.Update(keySpace())
	if m.Selection.State("readme.txt") != CheckNone {
		t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("readme.txt"))
	}
}

func TestFilePickerSpaceTogglesDirectory(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// First entry should be docs/ (dirs sorted before files).
	if m.HighlightedPath() != "docs" {
		t.Fatalf("expected cursor on docs, got %q", m.HighlightedPath())
	}

	// Space should select the entire docs subtree.
	m, _ = m.Update(keySpace())
	if m.Selection.State("docs") != CheckAll {
		t.Errorf("after space: got %v, want CheckAll", m.Selection.State("docs"))
	}
	if m.Selection.State("docs/notes.txt") != CheckAll {
		t.Error("docs/notes.txt should be selected after toggling docs/")
	}

	// Space again deselects.
	m, _ = m.Update(keySpace())
	if m.Selection.State("docs") != CheckNone {
		t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("docs"))
	}
}

func TestFilePickerOpenDescendsIntoDirectory(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Cursor is on docs/ (first entry).
	if m.CurrentDirectory != "." {
		t.Fatalf("expected root directory, got %q", m.CurrentDirectory)
	}

	// Press l to open docs/.
	var cmd tea.Cmd
	m, cmd = m.Update(keyL())
	if m.CurrentDirectory != "docs" {
		t.Fatalf("expected CurrentDirectory=docs, got %q", m.CurrentDirectory)
	}

	// Process the readDir command to load the directory contents.
	if cmd == nil {
		t.Fatal("open should return a readDir command")
	}
	msg := cmd()
	m, _ = m.Update(msg)

	// docs/ contains only notes.txt.
	if len(m.files) != 1 || m.files[0].Name() != "notes.txt" {
		t.Errorf("expected [notes.txt], got %v", fileNames(m.files))
	}

	// Also test with the right arrow key.
	m2 := initPicker(t)
	m2, _ = m2.Update(keyRight())
	if m2.CurrentDirectory != "docs" {
		t.Fatalf("right arrow: expected CurrentDirectory=docs, got %q", m2.CurrentDirectory)
	}
}

func TestFilePickerOpenOnFileIsNoOp(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Navigate to readme.txt (index 2).
	m, _ = m.Update(keyJ()) // 0→1
	m, _ = m.Update(keyJ()) // 1→2

	if m.HighlightedPath() != "readme.txt" {
		t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath())
	}

	origDir := m.CurrentDirectory
	m, cmd := m.Update(keyL())
	if m.CurrentDirectory != origDir {
		t.Errorf("open on file changed directory to %q", m.CurrentDirectory)
	}
	if cmd != nil {
		t.Error("open on file should not return a command")
	}
}

func TestFilePickerBackReturnsToParent(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Navigate to music/ (index 1) and open it.
	m, _ = m.Update(keyJ()) // 0→1 (music/)
	if m.HighlightedPath() != "music" {
		t.Fatalf("expected cursor on music, got %q", m.HighlightedPath())
	}

	var cmd tea.Cmd
	m, cmd = m.Update(keyL()) // open music/
	if cmd != nil {
		m, _ = m.Update(cmd())
	}
	if m.CurrentDirectory != "music" {
		t.Fatalf("expected CurrentDirectory=music, got %q", m.CurrentDirectory)
	}

	// Press h to go back.
	m, cmd = m.Update(keyH())
	if m.CurrentDirectory != "." {
		t.Fatalf("expected CurrentDirectory=., got %q", m.CurrentDirectory)
	}
	if cmd != nil {
		m, _ = m.Update(cmd())
	}

	// Cursor should be restored to index 1 (where music/ was).
	if m.HighlightedPath() != "music" {
		t.Errorf("expected cursor restored to music, got %q", m.HighlightedPath())
	}
}

func TestFilePickerEnterConfirms(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Cursor is on docs/ (a directory). Enter should confirm, not descend.
	origDir := m.CurrentDirectory
	m, cmd := m.Update(keyEnter())

	if !m.Confirmed {
		t.Error("Enter should set Confirmed = true")
	}
	if m.CurrentDirectory != origDir {
		t.Errorf("Enter should not change directory, got %q", m.CurrentDirectory)
	}

	// The command should be tea.Quit.
	if cmd == nil {
		t.Fatal("Enter should return a command")
	}
	msg := cmd()
	if _, ok := msg.(tea.QuitMsg); !ok {
		t.Errorf("expected tea.QuitMsg, got %T", msg)
	}
}

func TestFilePickerNestedToggleUsesFullPath(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Navigate to music/ (index 1) and open it.
	m, _ = m.Update(keyJ())
	var cmd tea.Cmd
	m, cmd = m.Update(keyL()) // open music/
	if cmd != nil {
		m, _ = m.Update(cmd())
	}

	// Inside music/: album-01/, album-02/ (dirs sorted first).
	// Open album-01/.
	m, cmd = m.Update(keyL())
	if cmd != nil {
		m, _ = m.Update(cmd())
	}
	if m.CurrentDirectory != "music/album-01" {
		t.Fatalf("expected CurrentDirectory=music/album-01, got %q", m.CurrentDirectory)
	}

	// Files: 01-track.flac, 02-track.flac. Toggle the first one.
	if m.HighlightedPath() != "music/album-01/01-track.flac" {
		t.Fatalf("expected cursor on music/album-01/01-track.flac, got %q", m.HighlightedPath())
	}

	m, _ = m.Update(keySpace())
	if m.Selection.State("music/album-01/01-track.flac") != CheckAll {
		t.Error("space should toggle the full FS-relative path")
	}
	// Parent should be partial.
	if m.Selection.State("music/album-01") != CheckPartial {
		t.Errorf("album-01: got %v, want CheckPartial", m.Selection.State("music/album-01"))
	}
}

func TestFilePickerCursorBounds(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Root has 3 entries: docs/, music/, readme.txt (indices 0, 1, 2).

	// Pressing up at the top should clamp at 0.
	m, _ = m.Update(keyK())
	if m.selected != 0 {
		t.Errorf("up at top: expected selected=0, got %d", m.selected)
	}

	// Move to the bottom.
	m, _ = m.Update(keyJ()) // 0→1
	m, _ = m.Update(keyJ()) // 1→2
	if m.selected != 2 {
		t.Fatalf("expected selected=2, got %d", m.selected)
	}

	// Pressing down at the bottom should clamp at 2.
	m, _ = m.Update(keyJ())
	if m.selected != 2 {
		t.Errorf("down at bottom: expected selected=2, got %d", m.selected)
	}
}

func TestFilePickerBackAtRoot(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	origDir := m.CurrentDirectory
	origSelected := m.selected
	origFiles := len(m.files)

	// Press back at root — should be a no-op (no crash, stays at root).
	m, cmd := m.Update(keyH())

	if m.CurrentDirectory != origDir {
		t.Errorf("back at root changed directory to %q", m.CurrentDirectory)
	}
	if m.selected != origSelected {
		t.Errorf("back at root changed selected from %d to %d", origSelected, m.selected)
	}

	// Process the readDir command if returned (it re-reads root).
	if cmd != nil {
		msg := cmd()
		m, _ = m.Update(msg)
	}

	// Should still have the same entries.
	if len(m.files) != origFiles {
		t.Errorf("back at root changed file count from %d to %d", origFiles, len(m.files))
	}
}

func TestFilePickerCtrlCQuits(t *testing.T) {
	t.Parallel()
	m := initPicker(t)

	// Toggle something so we can verify it doesn't count as confirmed.
	m, _ = m.Update(keySpace())

	m, cmd := m.Update(keyCtrlC())

	if m.Confirmed {
		t.Error("ctrl+c should not set Confirmed")
	}
	if cmd == nil {
		t.Fatal("ctrl+c should return a command")
	}
	msg := cmd()
	if _, ok := msg.(tea.QuitMsg); !ok {
		t.Errorf("expected tea.QuitMsg, got %T", msg)
	}
}

// fileNames returns the names of directory entries for error messages.
func fileNames(entries []fs.DirEntry) []string {
	names := make([]string, len(entries))
	for i, e := range entries {
		names[i] = e.Name()
	}
	return names
}
