package ui

import (
	"image/color"
	"strings"
	"testing"

	"charm.land/bubbles/v2/key"
	tea "charm.land/bubbletea/v2"

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

// fakeScreen is a minimal Screen implementation for testing session
// navigation without depending on real UI components. It returns
// BackCmd on Esc to signal back-navigation, matching the expected
// behaviour for screens that don't use Esc internally.
type fakeScreen struct {
	title     string
	selection string
	initCalls int
}

func newFake(title string) *fakeScreen {
	return &fakeScreen{title: title}
}

func (f *fakeScreen) Init() tea.Cmd { f.initCalls++; return nil }

func (f *fakeScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
	if kp, ok := msg.(tea.KeyPressMsg); ok {
		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
			return f, BackCmd
		}
	}
	return f, nil
}

func (f *fakeScreen) View() string               { return f.title + " view" }
func (f *fakeScreen) Title() string              { return f.title }
func (f *fakeScreen) KeyBindings() []key.Binding { return nil }
func (f *fakeScreen) Selection() string          { return f.selection }

// complete marks the fake screen as having a selection for breadcrumb
// display purposes.
func (f *fakeScreen) complete(sel string) { f.selection = sel }

// selectingScreen completes itself on any non-Esc key press by setting
// its selection and returning DoneCmd to signal advancement.
type selectingScreen struct {
	fakeScreen
}

func newSelecting(title string) *selectingScreen {
	return &selectingScreen{fakeScreen: fakeScreen{title: title}}
}

func (s *selectingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
	if kp, ok := msg.(tea.KeyPressMsg); ok {
		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
			return s, BackCmd
		}
		s.selection = s.title
		return s, DoneCmd
	}
	return s, nil
}

// escConsumingScreen handles Esc internally (e.g. like a file picker)
// and does not return BackCmd.
type escConsumingScreen struct {
	fakeScreen
	escHandled bool
}

func newEscConsuming(title string) *escConsumingScreen {
	return &escConsumingScreen{fakeScreen: fakeScreen{title: title}}
}

func (e *escConsumingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
	if kp, ok := msg.(tea.KeyPressMsg); ok {
		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
			e.escHandled = true
			return e, nil // consume Esc, no BackMsg
		}
	}
	return e, nil
}

// sizeCaptureScreen records the height from WindowSizeMsg for testing
// that the session adjusts dimensions correctly.
type sizeCaptureScreen struct {
	fakeScreen
	onSize func(int)
}

func (s *sizeCaptureScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
	if wsm, ok := msg.(tea.WindowSizeMsg); ok {
		if s.onSize != nil {
			s.onSize(wsm.Height)
		}
	}
	return s.fakeScreen.Update(msg)
}

// defaultStyles returns a *theme.Styles for use in tests. Callers
// pass this to both New() and any screen constructors that need it.
func defaultStyles() *theme.Styles {
	s := theme.New(true)
	return &s
}

func escMsg() tea.Msg {
	return tea.KeyPressMsg{Code: tea.KeyEscape}
}

func ctrlCMsg() tea.Msg {
	return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
}

func enterMsg() tea.Msg {
	return tea.KeyPressMsg{Code: tea.KeyEnter}
}

// resizeMsg simulates a window resize — an unrelated background message.
func resizeMsg() tea.Msg {
	return tea.WindowSizeMsg{Width: 80, Height: 24}
}

func TestSessionInit(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newFake("second")
	s := New([]Screen{a, b}, defaultStyles())
	s.Init()

	if a.initCalls != 1 {
		t.Errorf("first screen Init called %d times, want 1", a.initCalls)
	}
	if b.initCalls != 0 {
		t.Errorf("second screen Init called %d times, want 0", b.initCalls)
	}
}

func TestCtrlCExits(t *testing.T) {
	t.Parallel()

	s := New([]Screen{newFake("one")}, defaultStyles())
	s.Init()

	result, cmd := s.Update(ctrlCMsg())
	sess := result.(Session)

	if !sess.done {
		t.Error("session should be done after Ctrl+C")
	}
	if cmd == nil {
		t.Error("expected tea.Quit command")
	}
}

func TestEscOnFirstScreenExits(t *testing.T) {
	t.Parallel()

	s := New([]Screen{newFake("one")}, defaultStyles())
	s.Init()

	// Esc is forwarded to the screen, which returns BackCmd.
	result, _ := s.Update(escMsg())
	// BackMsg triggers navigateBack.
	result, cmd := result.(Session).Update(BackMsg{})
	sess := result.(Session)

	if !sess.done {
		t.Error("Esc on first screen should exit")
	}
	if cmd == nil {
		t.Error("expected tea.Quit command")
	}
}

func TestEscNavigatesBack(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newFake("second")
	a.complete("picked")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1

	// Esc → screen returns BackCmd → session receives BackMsg.
	result, _ := s.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess := result.(Session)

	if sess.cursor != 0 {
		t.Errorf("cursor = %d, want 0 after Esc", sess.cursor)
	}
}

func TestBackNavigationPreservesState(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newFake("second")
	a.complete("picked")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1

	result, _ := s.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess := result.(Session)

	screen := sess.screens[0].(*fakeScreen)
	if screen.Selection() != "picked" {
		t.Errorf("selection = %q, want %q after back navigation", screen.Selection(), "picked")
	}
}

func TestBackNavDoesNotReAdvance(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newFake("second")
	a.complete("picked")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1

	// Navigate back to the first screen.
	result, _ := s.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess := result.(Session)

	if sess.cursor != 0 {
		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
	}

	// Send an unrelated message (window resize). The session should
	// NOT re-advance because advancement only happens via DoneMsg,
	// not by polling Selection().
	result, _ = sess.Update(resizeMsg())
	sess = result.(Session)

	if sess.cursor != 0 {
		t.Errorf("cursor = %d after resize on completed screen, want 0 (should not re-advance)", sess.cursor)
	}
}

func TestScreenAdvancesOnDoneMsg(t *testing.T) {
	t.Parallel()

	a := newSelecting("first")
	b := newFake("second")

	s := New([]Screen{a, b}, defaultStyles())
	s.Init()

	// Send a key to trigger selection on the first screen. The
	// selectingScreen returns DoneCmd, which produces DoneMsg.
	result, cmd := s.Update(enterMsg())
	// The cmd contains DoneCmd; execute it to produce DoneMsg.
	if cmd == nil {
		t.Fatal("expected DoneCmd from selecting screen")
	}
	// Simulate the runtime delivering the DoneMsg.
	result, _ = result.(Session).Update(DoneMsg{})
	sess := result.(Session)

	if sess.cursor != 1 {
		t.Errorf("cursor = %d, want 1 after DoneMsg", sess.cursor)
	}
	if b.initCalls != 1 {
		t.Errorf("second screen Init called %d times, want 1", b.initCalls)
	}
}

func TestReAdvanceAfterBackAndNewSelection(t *testing.T) {
	t.Parallel()

	a := newSelecting("first")
	b := newFake("second")

	s := New([]Screen{a, b}, defaultStyles())
	s.Init()

	// Advance past the first screen.
	result, _ := s.Update(enterMsg())
	result, _ = result.(Session).Update(DoneMsg{})
	sess := result.(Session)

	if sess.cursor != 1 {
		t.Fatalf("cursor = %d, want 1 after first advance", sess.cursor)
	}

	// Navigate back.
	result, _ = sess.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess = result.(Session)

	if sess.cursor != 0 {
		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
	}

	// Make a new selection. This should advance again.
	result, _ = sess.Update(enterMsg())
	result, _ = result.(Session).Update(DoneMsg{})
	sess = result.(Session)

	if sess.cursor != 1 {
		t.Errorf("cursor = %d, want 1 after re-selection", sess.cursor)
	}
}

func TestEscConsumedByScreen(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newEscConsuming("picker")
	a.complete("restore")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1

	// Esc is forwarded to the screen, which handles it internally
	// and does NOT return BackCmd.
	result, _ := s.Update(escMsg())
	sess := result.(Session)

	// Should still be on the same screen.
	if sess.cursor != 1 {
		t.Errorf("cursor = %d, want 1 (screen consumed Esc)", sess.cursor)
	}

	screen := sess.screens[1].(*escConsumingScreen)
	if !screen.escHandled {
		t.Error("screen should have handled Esc internally")
	}
}

func TestBreadcrumbEmpty(t *testing.T) {
	t.Parallel()

	s := New([]Screen{newFake("one")}, defaultStyles())
	crumb := s.breadcrumb()

	if crumb != "" {
		t.Errorf("breadcrumb on first screen = %q, want empty", crumb)
	}
}

func TestBreadcrumbShowsCompletedSelections(t *testing.T) {
	t.Parallel()

	a := newFake("cmd")
	b := newFake("preset")
	c := newFake("details")

	a.complete("restore")
	b.complete("home@cloud")

	s := New([]Screen{a, b, c}, defaultStyles())
	s.cursor = 2

	crumb := s.breadcrumb()
	if !strings.Contains(crumb, "restore") {
		t.Errorf("breadcrumb missing 'restore': %q", crumb)
	}
	if !strings.Contains(crumb, "home@cloud") {
		t.Errorf("breadcrumb missing 'home@cloud': %q", crumb)
	}
}

func TestBreadcrumbUpdatesOnBack(t *testing.T) {
	t.Parallel()

	a := newFake("cmd")
	b := newFake("preset")
	c := newFake("details")

	a.complete("restore")
	b.complete("home@cloud")

	s := New([]Screen{a, b, c}, defaultStyles())
	s.cursor = 2

	result, _ := s.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess := result.(Session)

	crumb := sess.breadcrumb()
	if !strings.Contains(crumb, "restore") {
		t.Errorf("breadcrumb after back missing 'restore': %q", crumb)
	}
	// On screen b now — b's selection should not be in the crumb
	// because the breadcrumb only shows screens before the current one.
	if strings.Contains(crumb, "home@cloud") {
		t.Errorf("breadcrumb after back should not contain current screen's selection: %q", crumb)
	}
}

func TestViewIncludesChrome(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	a.complete("restore")
	b := newFake("Second Step")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1
	s.width = 80
	s.height = 24

	view := s.View()

	if !strings.Contains(view.Content, "Second Step") {
		t.Error("view should contain the screen title")
	}
	if !strings.Contains(view.Content, "restore") {
		t.Error("view should contain the breadcrumb")
	}
	if !strings.Contains(view.Content, "esc") {
		t.Error("view should contain the help bar with esc binding")
	}
}

func TestViewEmptyScreens(t *testing.T) {
	t.Parallel()

	s := New([]Screen{}, defaultStyles())
	view := s.View()
	if view.Content != "" {
		t.Errorf("view with no screens should be empty, got %q", view.Content)
	}
}

func TestBackNavReInitsScreen(t *testing.T) {
	t.Parallel()

	a := newFake("first")
	b := newFake("second")
	a.complete("cmd")

	s := New([]Screen{a, b}, defaultStyles())
	s.cursor = 1

	result, _ := s.Update(escMsg())
	result.(Session).Update(BackMsg{})

	// After navigating back, the first screen should be re-inited.
	if a.initCalls != 1 {
		t.Errorf("first screen Init called %d times after back nav, want 1", a.initCalls)
	}
}

func TestDoneOnLastScreenCompletes(t *testing.T) {
	t.Parallel()

	a := newSelecting("only")
	s := New([]Screen{a}, defaultStyles())
	s.Init()

	// Select on the only screen — DoneMsg should complete the session.
	result, _ := s.Update(enterMsg())
	result, cmd := result.(Session).Update(DoneMsg{})
	sess := result.(Session)

	if !sess.done {
		t.Error("session should be done after DoneMsg on last screen")
	}
	if !sess.Completed() {
		t.Error("session should be completed (not cancelled)")
	}
	if cmd == nil {
		t.Error("expected tea.Quit command")
	}
}

func TestCtrlCIsNotCompleted(t *testing.T) {
	t.Parallel()

	s := New([]Screen{newFake("one")}, defaultStyles())
	s.Init()

	result, _ := s.Update(ctrlCMsg())
	sess := result.(Session)

	if !sess.done {
		t.Error("session should be done after Ctrl+C")
	}
	if sess.Completed() {
		t.Error("session should NOT be completed after cancellation")
	}
}

func TestEscExitIsNotCompleted(t *testing.T) {
	t.Parallel()

	s := New([]Screen{newFake("one")}, defaultStyles())
	s.Init()

	result, _ := s.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess := result.(Session)

	if !sess.done {
		t.Error("session should be done after Esc on first screen")
	}
	if sess.Completed() {
		t.Error("session should NOT be completed after Esc exit")
	}
}

func TestWindowSizeAdjustedForChrome(t *testing.T) {
	t.Parallel()

	var receivedHeight int
	screen := &sizeCaptureScreen{
		fakeScreen: fakeScreen{title: "test"},
		onSize:     func(h int) { receivedHeight = h },
	}

	s := New([]Screen{screen}, defaultStyles())
	s.Init()

	s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})

	wantHeight := 30 - chromeLines
	if receivedHeight != wantHeight {
		t.Errorf("screen received height %d, want %d (terminal height minus chrome)", receivedHeight, wantHeight)
	}
}

// themeAwareScreen holds a *theme.Styles pointer, as real screen
// implementations will. This lets us verify that the session's theme
// update propagates through the shared pointer.
type themeAwareScreen struct {
	fakeScreen
	styles *theme.Styles
}

// bgCapturingScreen records whether it received a BackgroundColorMsg.
type bgCapturingScreen struct {
	fakeScreen
	onBg func()
}

func (b *bgCapturingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
	if _, ok := msg.(tea.BackgroundColorMsg); ok {
		if b.onBg != nil {
			b.onBg()
		}
		return b, nil
	}
	return b.fakeScreen.Update(msg)
}

func newThemeAware(title string, styles *theme.Styles) *themeAwareScreen {
	return &themeAwareScreen{
		fakeScreen: fakeScreen{title: title},
		styles:     styles,
	}
}

func TestThemeUpdatePropagesToScreens(t *testing.T) {
	t.Parallel()

	// Caller owns the styles allocation and passes the same pointer
	// to both screen constructors and the session.
	styles := defaultStyles()
	screen := newThemeAware("test", styles)
	s := New([]Screen{screen}, styles)

	if !screen.styles.Dark {
		t.Fatal("precondition: styles should default to dark")
	}

	// Simulate a light terminal background detection.
	result, _ := s.Update(tea.BackgroundColorMsg{Color: color.White})
	_ = result.(Session)

	// The screen's pointer should now see the updated (light) styles
	// because the session mutated through the shared pointer.
	if screen.styles.Dark {
		t.Error("screen still sees dark styles after light BackgroundColorMsg; session replaced pointer instead of mutating in place")
	}
}

func TestBackgroundColorForwardedToScreen(t *testing.T) {
	t.Parallel()

	// Screens that cache themed state need to see BackgroundColorMsg
	// so they can rebuild. Verify the session forwards it.
	var received bool
	screen := &bgCapturingScreen{
		fakeScreen: fakeScreen{title: "test"},
		onBg:       func() { received = true },
	}

	styles := defaultStyles()
	s := New([]Screen{screen}, styles)
	s.Init()

	s.Update(tea.BackgroundColorMsg{Color: color.White})

	if !received {
		t.Error("BackgroundColorMsg was not forwarded to the active screen")
	}
}

func TestExtendAppendsScreens(t *testing.T) {
	t.Parallel()

	a := newSelecting("first")
	s := New([]Screen{a}, defaultStyles())
	s.Init()

	// Extend the session with two more screens before the current
	// screen completes.
	b := newFake("second")
	c := newFake("third")
	result, _ := s.Update(ExtendMsg{Screens: []Screen{b, c}})
	sess := result.(Session)

	if len(sess.screens) != 3 {
		t.Fatalf("screens = %d, want 3 after extend", len(sess.screens))
	}

	// The cursor should still be on the first screen.
	if sess.cursor != 0 {
		t.Errorf("cursor = %d, want 0 (extend should not advance)", sess.cursor)
	}
}

func TestExtendedScreensReachableViaAdvance(t *testing.T) {
	t.Parallel()

	a := newSelecting("first")
	s := New([]Screen{a}, defaultStyles())
	s.Init()

	// Extend before completing the first screen.
	b := newFake("second")
	result, _ := s.Update(ExtendMsg{Screens: []Screen{b}})
	sess := result.(Session)

	// Complete the first screen. Without extend, this would finish
	// the session. With extend, it should advance to b.
	result, _ = sess.Update(enterMsg())
	result, _ = result.(Session).Update(DoneMsg{})
	sess = result.(Session)

	if sess.cursor != 1 {
		t.Errorf("cursor = %d, want 1 after advance to extended screen", sess.cursor)
	}
	if sess.done {
		t.Error("session should not be done — extended screen is still pending")
	}
}

func TestExtendedScreensGetSizeOnActivation(t *testing.T) {
	t.Parallel()

	var receivedHeight int
	a := newSelecting("first")
	b := &sizeCaptureScreen{
		fakeScreen: fakeScreen{title: "extended"},
		onSize:     func(h int) { receivedHeight = h },
	}

	s := New([]Screen{a}, defaultStyles())
	s.Init()

	// Set terminal size, then extend, then advance.
	result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
	sess := result.(Session)

	result, _ = sess.Update(ExtendMsg{Screens: []Screen{b}})
	sess = result.(Session)

	result, _ = sess.Update(enterMsg())
	result.(Session).Update(DoneMsg{})

	wantHeight := 30 - chromeLines
	if receivedHeight != wantHeight {
		t.Errorf("extended screen received height %d, want %d", receivedHeight, wantHeight)
	}
}

func TestExtendEmptySliceIsNoop(t *testing.T) {
	t.Parallel()

	a := newFake("only")
	s := New([]Screen{a}, defaultStyles())
	s.Init()

	result, _ := s.Update(ExtendMsg{Screens: nil})
	sess := result.(Session)

	if len(sess.screens) != 1 {
		t.Errorf("screens = %d, want 1 after empty extend", len(sess.screens))
	}
}

func TestExtendBackNavThroughExtendedScreens(t *testing.T) {
	t.Parallel()

	a := newSelecting("first")
	b := newFake("extended")

	s := New([]Screen{a}, defaultStyles())
	s.Init()

	// Extend, advance, then back-nav.
	result, _ := s.Update(ExtendMsg{Screens: []Screen{b}})
	sess := result.(Session)

	// Advance past first screen.
	result, _ = sess.Update(enterMsg())
	result, _ = result.(Session).Update(DoneMsg{})
	sess = result.(Session)

	if sess.cursor != 1 {
		t.Fatalf("cursor = %d, want 1 before back nav", sess.cursor)
	}

	// Back nav should return to the first screen.
	result, _ = sess.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess = result.(Session)

	if sess.cursor != 0 {
		t.Errorf("cursor = %d, want 0 after back through extended screen", sess.cursor)
	}
}

func TestExtendTruncatesScreensBeyondCursor(t *testing.T) {
	t.Parallel()

	// If the user backs up and a new ExtendMsg arrives, screens after
	// the cursor should be replaced by the new ones (the old future
	// is no longer valid).
	a := newSelecting("cmd")
	b := newFake("old-future")

	s := New([]Screen{a, b}, defaultStyles())
	s.Init()

	// Advance to b, then back to a.
	result, _ := s.Update(enterMsg())
	result, _ = result.(Session).Update(DoneMsg{})
	sess := result.(Session)

	result, _ = sess.Update(escMsg())
	result, _ = result.(Session).Update(BackMsg{})
	sess = result.(Session)

	// Now extend with new screens. The old b should be replaced.
	c := newFake("new-future-1")
	d := newFake("new-future-2")
	result, _ = sess.Update(ExtendMsg{Screens: []Screen{c, d}})
	sess = result.(Session)

	// Should have a + c + d = 3 screens, not a + b + c + d.
	if len(sess.screens) != 3 {
		t.Errorf("screens = %d, want 3 (old future should be replaced)", len(sess.screens))
	}

	// The screen at index 1 should be c, not b.
	if sess.screens[1].Title() != "new-future-1" {
		t.Errorf("screen[1].Title() = %q, want %q", sess.screens[1].Title(), "new-future-1")
	}
}

func TestSizeReplayedOnAdvance(t *testing.T) {
	t.Parallel()

	var receivedHeight int
	a := newSelecting("first")
	b := &sizeCaptureScreen{
		fakeScreen: fakeScreen{title: "second"},
		onSize:     func(h int) { receivedHeight = h },
	}

	s := New([]Screen{a, b}, defaultStyles())
	s.Init()

	// Set terminal size so the session caches it.
	result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
	sess := result.(Session)

	// Advance to second screen. The session sends the adjusted size
	// directly to the new screen during advance().
	result, _ = sess.Update(enterMsg())
	result.(Session).Update(DoneMsg{})

	wantHeight := 30 - chromeLines
	if receivedHeight != wantHeight {
		t.Errorf("second screen received height %d after advance, want %d", receivedHeight, wantHeight)
	}
}
