package ui

import (
	"strings"
	"testing"

	tea "charm.land/bubbletea/v2"
	"github.com/mark3labs/kit/internal/app"
	"github.com/mark3labs/kit/internal/ui/core"
)

// ==========================================================================
// InputComponent tests
// ==========================================================================

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

// newTestInput creates an InputComponent with the given AppController (may be nil).
func newTestInput(ctrl AppController) *InputComponent {
	return NewInputComponent(80, "test input", ctrl)
}

// sendInputMsg calls component.Update with the given message, returns the
// updated component and the cmd.
func sendInputMsg(c *InputComponent, msg tea.Msg) (*InputComponent, tea.Cmd) {
	m, cmd := c.Update(msg)
	return m.(*InputComponent), cmd
}

// runCmd executes a tea.Cmd and returns the resulting tea.Msg.
// Returns nil if cmd is nil.
func runCmd(cmd tea.Cmd) tea.Msg {
	if cmd == nil {
		return nil
	}
	return cmd()
}

// --------------------------------------------------------------------------
// TestInputComponent_SubmitEmitsSubmitMsg verifies that pressing enter on a
// non-empty textarea emits a submitMsg with the typed text.
// --------------------------------------------------------------------------

func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) {
	ctrl := &stubAppController{}
	c := newTestInput(ctrl)

	// Type text directly into the textarea (bypassing key events to keep the
	// test simple — we only care about the submit path here).
	c.textarea.SetValue("hello world")
	c.lastValue = "hello world"

	// Press enter via key press (no popup visible).
	_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})

	msg := runCmd(cmd)
	if msg == nil {
		t.Fatal("expected a cmd from pressing enter on non-empty input")
	}

	sm, ok := msg.(core.SubmitMsg)
	if !ok {
		t.Fatalf("expected submitMsg, got %T", msg)
	}
	if sm.Text != "hello world" {
		t.Fatalf("expected Text='hello world', got %q", sm.Text)
	}
}

// TestInputComponent_CtrlD_SubmitEmitsSubmitMsg verifies that ctrl+d also
// submits the text.
func TestInputComponent_CtrlD_SubmitEmitsSubmitMsg(t *testing.T) {
	ctrl := &stubAppController{}
	c := newTestInput(ctrl)

	c.textarea.SetValue("ctrl+d submit")
	c.lastValue = "ctrl+d submit"

	_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})

	msg := runCmd(cmd)
	if msg == nil {
		t.Fatal("expected a cmd from ctrl+d on non-empty input")
	}
	sm, ok := msg.(core.SubmitMsg)
	if !ok {
		t.Fatalf("expected submitMsg from ctrl+d, got %T", msg)
	}
	if sm.Text != "ctrl+d submit" {
		t.Fatalf("expected Text='ctrl+d submit', got %q", sm.Text)
	}
}

// TestInputComponent_EmptySubmit_NoCmd verifies that submitting an empty or
// whitespace-only string produces no cmd.
func TestInputComponent_EmptySubmit_NoCmd(t *testing.T) {
	ctrl := &stubAppController{}
	c := newTestInput(ctrl)

	// textarea is empty by default
	_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
	if cmd != nil {
		t.Fatal("expected nil cmd from submitting empty input")
	}
}

// TestInputComponent_SubmitClearsTextarea verifies that after submit the
// textarea is cleared.
func TestInputComponent_SubmitClearsTextarea(t *testing.T) {
	ctrl := &stubAppController{}
	c := newTestInput(ctrl)

	c.textarea.SetValue("some text")
	c.lastValue = "some text"

	c, _ = sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})

	if c.textarea.Value() != "" {
		t.Fatalf("expected textarea to be cleared after submit, got %q", c.textarea.Value())
	}
}

// --------------------------------------------------------------------------
// TestInputComponent_QuitReturnsTeaQuit verifies that submitting /quit (and its
// aliases) returns a tea.Quit cmd.
// --------------------------------------------------------------------------

func TestInputComponent_QuitReturnsTeaQuit(t *testing.T) {
	aliases := []string{"/quit", "/q", "/exit"}
	ctrl := &stubAppController{}

	for _, alias := range aliases {
		t.Run(alias, func(t *testing.T) {
			c := newTestInput(ctrl)
			c.textarea.SetValue(alias)
			c.lastValue = alias

			_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
			if cmd == nil {
				t.Fatalf("%s: expected tea.Quit cmd, got nil", alias)
			}
			msg := runCmd(cmd)
			if _, ok := msg.(tea.QuitMsg); !ok {
				t.Fatalf("%s: expected QuitMsg, got %T", alias, msg)
			}
		})
	}
}

// --------------------------------------------------------------------------
// TestInputComponent_ClearForwardsAsSubmitMsg verifies that /clear (and its
// aliases) are forwarded as submitMsg to the parent model so that the parent
// can call ClearMessages(), update scrollback, and print the confirmation
// message in one place. InputComponent must NOT call ClearMessages() directly.
// --------------------------------------------------------------------------

func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
	aliases := []string{"/clear", "/c", "/cls"}
	for _, alias := range aliases {
		t.Run(alias, func(t *testing.T) {
			ctrl := &stubAppController{}
			c := newTestInput(ctrl)
			c.textarea.SetValue(alias)
			c.lastValue = alias

			_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})

			// InputComponent must NOT call ClearMessages() directly.
			if ctrl.clearMsgCalled != 0 {
				t.Fatalf("%s: InputComponent must not call ClearMessages(), got %d", alias, ctrl.clearMsgCalled)
			}
			// A submitMsg must be emitted so the parent model handles /clear.
			if cmd == nil {
				t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
			}
			msg := runCmd(cmd)
			sm, ok := msg.(core.SubmitMsg)
			if !ok {
				t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
			}
			if sm.Text != alias {
				t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
			}
		})
	}
}

// TestInputComponent_ClearNilCtrl_NoPanic verifies that /clear with a nil
// appCtrl does not panic. Since /clear is now forwarded to the parent via
// submitMsg, no appCtrl interaction happens in InputComponent at all.
func TestInputComponent_ClearNilCtrl_NoPanic(t *testing.T) {
	c := newTestInput(nil)
	c.textarea.SetValue("/clear")
	c.lastValue = "/clear"

	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("unexpected panic on /clear with nil controller: %v", r)
		}
	}()

	_, _ = sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
}

// --------------------------------------------------------------------------
// TestInputComponent_ClearQueue_ForwardsAsSubmitMsg verifies that /clear-queue
// (and its alias /cq) are forwarded as submitMsg to the parent model (so the
// parent can call ClearQueue and update queueCount directly, avoiding a
// deadlock from calling prog.Send within Update).
// --------------------------------------------------------------------------

func TestInputComponent_ClearQueue_ForwardsAsSubmitMsg(t *testing.T) {
	aliases := []string{"/clear-queue", "/cq"}
	for _, alias := range aliases {
		t.Run(alias, func(t *testing.T) {
			ctrl := &stubAppController{}
			c := newTestInput(ctrl)
			c.textarea.SetValue(alias)
			c.lastValue = alias

			_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})

			// ClearQueue should NOT be called directly by InputComponent.
			if ctrl.clearQueueCalled != 0 {
				t.Fatalf("%s: expected ClearQueue() not called, got %d", alias, ctrl.clearQueueCalled)
			}
			// Instead, a submitMsg should be emitted so the parent handles it.
			if cmd == nil {
				t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
			}
			msg := runCmd(cmd)
			sm, ok := msg.(core.SubmitMsg)
			if !ok {
				t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
			}
			if sm.Text != alias {
				t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
			}
		})
	}
}

// --------------------------------------------------------------------------
// TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit verifies that a
// slash command not in the registry is forwarded as a submitMsg.
// --------------------------------------------------------------------------

func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
	ctrl := &stubAppController{}
	c := newTestInput(ctrl)
	c.textarea.SetValue("/unknown-command")
	c.lastValue = "/unknown-command"

	_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})

	msg := runCmd(cmd)
	if msg == nil {
		t.Fatal("expected submitMsg for unknown slash command")
	}
	sm, ok := msg.(core.SubmitMsg)
	if !ok {
		t.Fatalf("expected submitMsg for unknown slash command, got %T", msg)
	}
	if sm.Text != "/unknown-command" {
		t.Fatalf("expected Text='/unknown-command', got %q", sm.Text)
	}
}

// ==========================================================================
// StreamComponent tests
// ==========================================================================

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

// newTestStream creates a StreamComponent with a fixed width and model name.
func newTestStream() *StreamComponent {
	return NewStreamComponent(80, "test-model")
}

// sendStreamMsg calls component.Update and returns the updated component.
func sendStreamMsg(c *StreamComponent, msg tea.Msg) *StreamComponent {
	m, _ := c.Update(msg)
	return m.(*StreamComponent)
}

// --------------------------------------------------------------------------
// TestStreamComponent_Init_ReturnsNil verifies Init() returns nil (no startup cmd).
// --------------------------------------------------------------------------

func TestStreamComponent_Init_ReturnsNil(t *testing.T) {
	c := newTestStream()
	cmd := c.Init()
	if cmd != nil {
		t.Fatal("expected Init() to return nil cmd")
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_SpinnerTransition verifies that SpinnerEvent{Show:true}
// starts spinning and transitions phase from idle → active.
// --------------------------------------------------------------------------

func TestStreamComponent_SpinnerTransition(t *testing.T) {
	c := newTestStream()

	if c.phase != streamPhaseIdle {
		t.Fatalf("expected streamPhaseIdle initially, got %v", c.phase)
	}

	_, cmd := c.Update(app.SpinnerEvent{Show: true})

	if !c.spinning {
		t.Fatal("expected spinning=true after SpinnerEvent{Show:true}")
	}
	if c.phase != streamPhaseActive {
		t.Fatalf("expected streamPhaseActive after SpinnerEvent{Show:true}, got %v", c.phase)
	}
	// A tick cmd should have been returned to start the animation loop.
	if cmd == nil {
		t.Fatal("expected tick cmd from SpinnerEvent{Show:true}")
	}
}

// TestStreamComponent_SpinnerShowFalse_NoTransitionFromIdle verifies that
// SpinnerEvent{Show:false} when idle has no effect.
func TestStreamComponent_SpinnerShowFalse_NoTransitionFromIdle(t *testing.T) {
	c := newTestStream()

	c = sendStreamMsg(c, app.SpinnerEvent{Show: false})

	if c.phase != streamPhaseIdle {
		t.Fatalf("expected streamPhaseIdle after SpinnerEvent{Show:false}, got %v", c.phase)
	}
	if c.spinning {
		t.Fatal("expected spinning=false after SpinnerEvent{Show:false}")
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_SpinnerKeepsRunningDuringStreaming verifies that
// receiving a StreamChunkEvent keeps the spinner running alongside text.
// --------------------------------------------------------------------------

func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
	c := newTestStream()

	// Start spinner.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	if !c.spinning {
		t.Fatal("precondition: expected spinning=true")
	}

	// Receive first chunk — spinner should keep running.
	c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})

	// Flush pending chunks (simulates the 16ms tick firing).
	c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})

	if !c.spinning {
		t.Fatal("expected spinning=true after first chunk")
	}
	if c.phase != streamPhaseActive {
		t.Fatalf("expected streamPhaseActive after first chunk, got %v", c.phase)
	}
	if c.streamContent.String() != "hello" {
		t.Fatalf("expected streamContent='hello', got %q", c.streamContent.String())
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_ChunkAccumulation verifies that multiple StreamChunkEvents
// accumulate in order.
// --------------------------------------------------------------------------

func TestStreamComponent_ChunkAccumulation(t *testing.T) {
	c := newTestStream()

	chunks := []string{"Hello", ", ", "world", "!"}
	for _, chunk := range chunks {
		c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
	}

	// Flush pending chunks (simulates the 16ms tick firing).
	c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})

	got := c.streamContent.String()
	want := "Hello, world!"
	if got != want {
		t.Fatalf("expected accumulated content %q, got %q", want, got)
	}
	if c.phase != streamPhaseActive {
		t.Fatalf("expected streamPhaseActive, got %v", c.phase)
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_ToolExecution_IsStarting shows spinner during execution.
// --------------------------------------------------------------------------

func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
	c := newTestStream()

	_, cmd := c.Update(app.ToolExecutionEvent{
		ToolCallID: "call-exec-1",
		ToolName:   "exec_tool",
		IsStarting: true,
	})

	if !c.spinning {
		t.Fatal("expected spinning=true during tool execution")
	}
	tools := c.activeToolDisplays()
	if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
		t.Fatalf("expected activeTools to contain tool name, got %v", tools)
	}
	if cmd == nil {
		t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
	}
}

// TestStreamComponent_ToolExecution_NotStarting clears label but keeps spinning.
func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
	c := newTestStream()
	// Start spinning first (simulating execution in progress).
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	// Simulate a tool starting
	c = sendStreamMsg(c, app.ToolExecutionEvent{
		ToolCallID: "call-some-1",
		ToolName:   "some_tool",
		IsStarting: true,
	})

	c = sendStreamMsg(c, app.ToolExecutionEvent{
		ToolCallID: "call-some-1",
		ToolName:   "some_tool",
		IsStarting: false,
	})

	if !c.spinning {
		t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
	}
	if len(c.activeTools) != 0 {
		t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
	}
}

// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
	c := newTestStream()

	// Start three tools in parallel
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})

	if len(c.activeTools) != 3 {
		t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
	}

	// Check SpinnerView shows all tools
	view := c.SpinnerView()
	if !strings.Contains(view, "Running:") {
		t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
	}

	// Finish one tool
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
	if len(c.activeTools) != 2 {
		t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
	}

	// Finish remaining tools
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
	if len(c.activeTools) != 0 {
		t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
	}
}

// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
// tool call does not remove another concurrent call with the same tool name.
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
	c := newTestStream()

	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})

	tools := c.activeToolDisplays()
	if len(tools) != 2 {
		t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
	}

	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
	tools = c.activeToolDisplays()
	if len(tools) != 1 {
		t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
	}

	c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
	if len(c.activeToolDisplays()) != 0 {
		t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
// text when content is accumulated, and empty string when not.
// --------------------------------------------------------------------------

func TestStreamComponent_GetRenderedContent_Empty(t *testing.T) {
	c := newTestStream()
	if got := c.GetRenderedContent(); got != "" {
		t.Fatalf("expected empty GetRenderedContent on idle component, got %q", got)
	}
}

func TestStreamComponent_GetRenderedContent_WithText(t *testing.T) {
	c := newTestStream()
	c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello world"})
	got := c.GetRenderedContent()
	if got == "" {
		t.Fatal("expected non-empty GetRenderedContent after chunks")
	}
	// The rendered output contains ANSI escape codes from the message renderer,
	// so check for the text fragments rather than an exact substring.
	if !strings.Contains(got, "hello") {
		t.Fatalf("expected rendered content to contain 'hello', got %q", got)
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_Reset clears all accumulated state.
// --------------------------------------------------------------------------

func TestStreamComponent_Reset(t *testing.T) {
	c := newTestStream()

	// Accumulate some state.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	c = sendStreamMsg(c, app.StreamChunkEvent{Content: "some text"})
	c.spinnerFrame = 5

	c.Reset()

	if c.phase != streamPhaseIdle {
		t.Fatalf("expected streamPhaseIdle after Reset(), got %v", c.phase)
	}
	if c.spinning {
		t.Fatal("expected spinning=false after Reset()")
	}
	if c.spinnerFrame != 0 {
		t.Fatalf("expected spinnerFrame=0 after Reset(), got %d", c.spinnerFrame)
	}
	if c.streamContent.String() != "" {
		t.Fatalf("expected empty streamContent after Reset(), got %q", c.streamContent.String())
	}
	if !c.timestamp.IsZero() {
		t.Fatal("expected zero timestamp after Reset()")
	}
	if len(c.activeTools) != 0 {
		t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_SetHeight propagates to height field.
// --------------------------------------------------------------------------

func TestStreamComponent_SetHeight(t *testing.T) {
	c := newTestStream()
	c.SetHeight(20)
	if c.height != 20 {
		t.Fatalf("expected height=20, got %d", c.height)
	}
}

// TestStreamComponent_SetHeight_Negative_ClampsToZero verifies negative values
// are clamped to 0.
func TestStreamComponent_SetHeight_Negative_ClampsToZero(t *testing.T) {
	c := newTestStream()
	c.SetHeight(-5)
	if c.height != 0 {
		t.Fatalf("expected height=0 for negative input, got %d", c.height)
	}
}

// --------------------------------------------------------------------------
// TestStreamComponent_SpinnerTick advances the frame counter.
// --------------------------------------------------------------------------

func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
	c := newTestStream()

	// Start spinning first.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	initialFrame := c.spinnerFrame
	gen := c.spinnerGeneration

	// Send a tick with the current generation.
	_, cmd := c.Update(streamSpinnerTickMsg{generation: gen})

	if c.spinnerFrame != initialFrame+1 {
		t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
	}
	// The tick should re-schedule itself while spinning.
	if cmd == nil {
		t.Fatal("expected tick cmd to be re-scheduled while spinning")
	}
}

// TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning verifies that a
// tick when not spinning does not re-schedule.
func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T) {
	c := newTestStream()
	// spinning is false — tick should be ignored.
	_, cmd := c.Update(streamSpinnerTickMsg{})
	if cmd != nil {
		t.Fatal("expected no tick reschedule when not spinning")
	}
}

// TestStreamComponent_StaleTick_Discarded verifies that a tick from a previous
// spinner generation is silently discarded, preventing duplicate concurrent
// tick loops that would double the animation speed.
func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
	c := newTestStream()

	// Start spinner → generation 1.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	staleGen := c.spinnerGeneration

	// Stop spinner → generation bumped to 2.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: false})

	// Restart spinner → generation bumped to 3.
	c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
	currentGen := c.spinnerGeneration
	frameBefore := c.spinnerFrame

	// Simulate a stale tick from the first spinner session arriving.
	_, cmd := c.Update(streamSpinnerTickMsg{generation: staleGen})
	if c.spinnerFrame != frameBefore {
		t.Fatalf("stale tick should not advance frame: expected %d, got %d", frameBefore, c.spinnerFrame)
	}
	if cmd != nil {
		t.Fatal("stale tick should not reschedule")
	}

	// A tick from the current generation should still work.
	_, cmd = c.Update(streamSpinnerTickMsg{generation: currentGen})
	if c.spinnerFrame != frameBefore+1 {
		t.Fatalf("current-gen tick should advance frame: expected %d, got %d", frameBefore+1, c.spinnerFrame)
	}
	if cmd == nil {
		t.Fatal("current-gen tick should reschedule")
	}
}

// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
// previous generation (e.g. pre-Reset) are ignored.
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
	c := newTestStream()

	// Start a pending flush and capture its generation.
	c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
	staleGen := c.flushGeneration
	if !c.flushPending {
		t.Fatal("precondition: expected flushPending=true after first chunk")
	}

	// Reset should invalidate in-flight flush ticks.
	c.Reset()
	if c.flushGeneration == staleGen {
		t.Fatal("expected flushGeneration to change after Reset")
	}

	// New content in a new generation.
	c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
	if got := c.pendingStream.String(); got != "new" {
		t.Fatalf("expected pendingStream='new', got %q", got)
	}

	// Stale flush tick should be ignored.
	c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
	if got := c.pendingStream.String(); got != "new" {
		t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
	}

	// Current generation flush should commit.
	c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
	if got := c.pendingStream.String(); got != "" {
		t.Fatalf("expected pendingStream empty after current flush, got %q", got)
	}
	if got := c.streamContent.String(); got != "new" {
		t.Fatalf("expected streamContent='new' after current flush, got %q", got)
	}
}

// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoOp(t *testing.T) {
	c := newTestStream()
	// Commit some content directly.
	c.streamContent.WriteString("line1\nline2\nline3")
	c.phase = streamPhaseActive

	// ConsumeOverflow is a no-op in alt screen mode — always returns "".
	if got := c.ConsumeOverflow(); got != "" {
		t.Fatalf("expected empty from no-op ConsumeOverflow, got %q", got)
	}

	// Also returns "" with a height set.
	c.height = 2
	if got := c.ConsumeOverflow(); got != "" {
		t.Fatalf("expected empty from no-op ConsumeOverflow with height, got %q", got)
	}
}

// TestStreamComponent_GetRenderedContent_ReturnsAll verifies that
// GetRenderedContent returns all accumulated content.
func TestStreamComponent_GetRenderedContent_ReturnsAll(t *testing.T) {
	c := newTestStream()
	c.renderer = nil
	c.phase = streamPhaseActive

	c.streamContent.WriteString("a\nb\nc\nd\ne")

	got := c.GetRenderedContent()
	if got != "a\nb\nc\nd\ne" {
		t.Fatalf("expected full content, got %q", got)
	}
}
