package app

import (
	"errors"
	"io"
	"regexp"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"tobi/internal/session"
	"tobi/internal/termcolor"
)

var ansi = regexp.MustCompile("\\x1b\\[[0-9;]*m")

func TestViewNeverExceedsWidth(t *testing.T) {
	for _, width := range []int{0, 24, 30, 40, 60, 76, 100, 300} {
		f := textinput.New()
		f.SetWidth(max(0, min(width, maxUIWidth)-8))
		f.SetValue(strings.Repeat("nomatch", 20)) // a filter nothing matches, to cover the empty state
		m := pickerModel{width: width, filter: f, th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{
			{name: "selfish", local: true, sessions: []session.Session{{ID: "a", Command: "hx", Cwd: "/tmp", StartedAt: time.Now()}}},
			{name: "caladon", sessions: []session.Session{{ID: "b", Command: "nu", Cwd: "/x", StartedAt: time.Now()}}},
			{name: "pupos", unreachable: true}, {name: "homura", peerOffline: true},
		}}
		m.move(1) // the cursor has to survive a filter that left nothing to pick
		for n, line := range strings.Split(m.view(), "\n") {
			if got, max := utf8.RuneCountInString(ansi.ReplaceAllString(line, "")), min(width, maxUIWidth); got > max {
				t.Fatalf("width %d line %d: %d > %d", width, n, got, max)
			}
		}
	}
}

func TestKillFlow(t *testing.T) {
	a := session.Session{ID: "a", Host: "caladon", Command: "hx", StartedAt: time.Now()}
	m := pickerModel{width: 80, filter: textinput.New(), th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
		rows: []hostRow{{name: "caladon", sessions: []session.Session{a}}}}
	m.move(1)
	next, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
	if m = next.(pickerModel); cmd == nil || !m.killing[a.Canonical()] {
		t.Fatal("ctrl+x should fire the kill")
	}
	if _, cmd = m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl}); cmd != nil {
		t.Fatal("a kill in flight should not fire again")
	}

	next, _ = m.Update(killResult{a, errors.New("remote tobi is\ntoo old")})
	if m = next.(pickerModel); m.killing[a.Canonical()] || !strings.Contains(m.view(), "remote tobi is too old") {
		t.Fatal("a failed kill should clear the flight and show the error on one line")
	}
	next, _ = m.Update(killResult{a, nil})
	if m = next.(pickerModel); len(m.rows[0].sessions) != 0 {
		t.Fatal("a killed session should leave its row")
	}
}

func TestRunProbeConsumesRepliesNotInput(t *testing.T) {
	reads := make(chan stdinRead, 8)
	reads <- stdinRead{[]byte("\x1b]11;rgb:2222/3333/4444\x1b\\"), nil}
	reads <- stdinRead{[]byte("\x1b[?62;1cx"), nil}
	reads <- stdinRead{[]byte("x"), nil}
	var pushed []string
	colors := runProbe(reads, io.Discard, func(b []byte) { pushed = append(pushed, string(b)) }, 50*time.Millisecond)
	if colors[termcolor.Background] != (termcolor.Color{R: 0x2222, G: 0x3333, B: 0x4444}) {
		t.Fatalf("probe colors = %v", colors)
	}
	if len(pushed) != 1 || pushed[0] != "x" {
		t.Fatalf("probe pushed %q, want only the keys after the sentinel", pushed)
	}
}

func TestRunProbeWritesQueryAndTimesOut(t *testing.T) {
	reads := make(chan stdinRead, 8)
	reads <- stdinRead{[]byte("\x1b]10;rgb:0102/0304/0506\x1b\\"), nil}
	var w strings.Builder
	colors := runProbe(reads, &w, func([]byte) { t.Fatal("nothing to push on timeout") }, 50*time.Millisecond)
	if w.String() != termcolor.ProbeQuery {
		t.Fatalf("probe wrote %q", w.String())
	}
	if colors[termcolor.Foreground] != (termcolor.Color{R: 0x0102, G: 0x0304, B: 0x0506}) {
		t.Fatalf("probe colors = %v", colors)
	}
}
