package app

import (
	"bytes"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/x/ansi"
	"github.com/usestrix/strix/tui/internal/protocol"
)

type recordingConn struct{ bytes.Buffer }

func (c *recordingConn) Close() error { return nil }

func commandFromCmd(t *testing.T, cmd tea.Cmd, connection *recordingConn) protocol.Envelope {
	t.Helper()
	if cmd == nil {
		t.Fatal("expected command")
	}
	msg := cmd()
	if sent, ok := msg.(sentMsg); !ok || sent.err != nil {
		t.Fatalf("command failed: %#v", msg)
	}
	raw := connection.Bytes()
	if len(raw) < 4 {
		t.Fatalf("short command frame: %d bytes", len(raw))
	}
	size := int(binary.BigEndian.Uint32(raw[:4]))
	if len(raw) != size+4 {
		t.Fatalf("command frame size = %d, want %d", len(raw), size+4)
	}
	var envelope protocol.Envelope
	if err := json.Unmarshal(raw[4:], &envelope); err != nil {
		t.Fatal(err)
	}
	return envelope
}

func newCommandTestModel(t *testing.T) (Model, *recordingConn) {
	t.Helper()
	connection := &recordingConn{}
	return New(&Client{conn: connection}), connection
}

func handleCommandResult(t *testing.T, model *Model, command string, result any) tea.Cmd {
	t.Helper()
	resultPayload, err := json.Marshal(result)
	if err != nil {
		t.Fatal(err)
	}
	payload, err := json.Marshal(protocol.CommandResult{OK: true, Command: command, Result: resultPayload})
	if err != nil {
		t.Fatal(err)
	}
	if model.client == nil {
		model.client = newClient(&recordingConn{})
	}
	if model.client.pending == nil {
		model.client.pending = map[string]string{}
		model.client.pendingByKey = map[string]string{}
		model.client.requestKeyByID = map[string]string{}
	}
	requestID := fmt.Sprintf("test-%d", len(model.client.pending)+1)
	model.client.pending[requestID] = command
	model.client.pendingByKey[command] = requestID
	model.client.requestKeyByID[requestID] = command
	return model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "command_result", RequestID: requestID, Payload: payload})
}

func stateEnvelope(t *testing.T, revision int, state protocol.Snapshot) protocol.Envelope {
	t.Helper()
	payload, err := json.Marshal(protocol.StateUpdate{Revision: revision, State: state})
	if err != nil {
		t.Fatal(err)
	}
	return protocol.Envelope{Version: protocol.Version, Type: "state", Payload: payload}
}

func rawJSON(t *testing.T, value any) json.RawMessage {
	t.Helper()
	raw, err := json.Marshal(value)
	if err != nil {
		t.Fatal(err)
	}
	return raw
}

func bootstrapEnvelope(t *testing.T, collection string, revision int, items ...any) protocol.Envelope {
	t.Helper()
	rawItems := make([]json.RawMessage, 0, len(items))
	for _, item := range items {
		rawItems = append(rawItems, rawJSON(t, item))
	}
	payload := protocol.CollectionBootstrap{
		Collection: collection, Revision: revision, Cursor: 0, NextCursor: len(rawItems), Done: true, Items: rawItems,
	}
	return protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, payload)}
}

func TestBackendDisconnectBecomesFatalUnlessUserIsQuitting(t *testing.T) {
	model := New(nil)
	updated, cmd := model.Update(wireErrMsg{err: fmt.Errorf("socket closed")})
	result := updated.(Model)
	if cmd == nil || result.FatalError() == nil {
		t.Fatalf("backend disconnect was not fatal: cmd=%v error=%v", cmd, result.FatalError())
	}

	model = New(nil)
	model.quitting = true
	updated, _ = model.Update(wireErrMsg{err: fmt.Errorf("socket closed")})
	if quitting := updated.(Model); quitting.FatalError() != nil {
		t.Fatalf("intentional quit became fatal: %v", quitting.FatalError())
	}
}

func TestCollectionBootstrapChunksAndVersionedDelta(t *testing.T) {
	model := New(nil)
	first := protocol.Event{ID: "event-1", Version: 0, Type: "chat", AgentID: "agent", Data: map[string]any{"content": "one"}}
	second := protocol.Event{ID: "event-2", Version: 0, Type: "chat", AgentID: "agent", Data: map[string]any{"content": "two"}}

	firstChunk := protocol.CollectionBootstrap{
		Collection: "events", Revision: 1, Cursor: 0, NextCursor: 1, Items: []json.RawMessage{rawJSON(t, first)},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, firstChunk)})
	if len(model.snapshot.Events) != 0 {
		t.Fatal("partial bootstrap mutated installed events")
	}
	lastChunk := protocol.CollectionBootstrap{
		Collection: "events", Revision: 1, Cursor: 1, NextCursor: 2, Done: true, Items: []json.RawMessage{rawJSON(t, second)},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, lastChunk)})
	if len(model.snapshot.Events) != 2 || model.collectionRevisions["events"] != 1 {
		t.Fatalf("bootstrap was not installed: %#v revisions=%#v", model.snapshot.Events, model.collectionRevisions)
	}

	first.Version = 1
	first.Data["content"] = "updated"
	delta := protocol.CollectionDelta{
		Collection: "events", BaseRevision: 1, Revision: 2, Cursor: 0, NextCursor: 1, Done: true,
		Operations: []protocol.CollectionOperation{{Op: "upsert", Item: rawJSON(t, first)}},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, delta)})
	if model.snapshot.Events[0].Version != 1 || model.snapshot.Events[0].Data["content"] != "updated" || model.collectionRevisions["events"] != 2 {
		t.Fatalf("delta was not applied: %#v", model.snapshot.Events[0])
	}

	deleteDelta := protocol.CollectionDelta{
		Collection: "events", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 1, Done: true,
		Operations: []protocol.CollectionOperation{{Op: "delete", ID: "event-2"}},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, deleteDelta)})
	if len(model.snapshot.Events) != 1 || model.snapshot.Events[0].ID != "event-1" || model.collectionRevisions["events"] != 3 {
		t.Fatalf("delete delta was not applied: %#v", model.snapshot.Events)
	}
}

func TestCollectionMismatchRequestsOneResync(t *testing.T) {
	connection := &recordingConn{}
	model := New(newClient(connection))
	model.collectionRevisions["events"] = 4
	bad := protocol.CollectionDelta{
		Collection: "events", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 0, Done: true,
	}

	cmd := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)})
	if cmd == nil {
		t.Fatal("revision mismatch did not request a resync")
	}
	message := cmd()
	if sent, ok := message.(sentMsg); !ok || sent.err != nil || sent.command != "collection.resync" {
		t.Fatalf("resync send = %#v", message)
	}
	if retry := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)}); retry != nil {
		t.Fatal("same mismatch submitted more than one resync")
	}
	if model.collectionRevisions["events"] != 4 {
		t.Fatal("mismatched delta mutated collection revision")
	}
}

func TestAgentsCollectionPreservesSelectedIDAcrossUpsertsAndDeletes(t *testing.T) {
	model := New(nil)
	model.handleEnvelope(bootstrapEnvelope(t, "agents", 1,
		protocol.Agent{ID: "root", Name: "Root", Status: "running"},
		protocol.Agent{ID: "selected", Name: "Selected", Status: "running"},
		protocol.Agent{ID: "other", Name: "Other", Status: "waiting"},
	))
	model.selectedAgent = 1

	updated := protocol.Agent{ID: "selected", Name: "Selected updated", Status: "budget_paused"}
	delta := protocol.CollectionDelta{
		Collection: "agents", BaseRevision: 1, Revision: 2, Cursor: 0, NextCursor: 2, Done: true,
		Operations: []protocol.CollectionOperation{
			{Op: "delete", ID: "root"},
			{Op: "upsert", Item: rawJSON(t, updated)},
		},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, delta)})
	if got := model.snapshot.Agents[model.selectedAgent].ID; got != "selected" {
		t.Fatalf("selected agent changed to %q after delta", got)
	}
	if model.snapshot.Agents[model.selectedAgent].Status != "budget_paused" {
		t.Fatalf("agent upsert was not applied: %#v", model.snapshot.Agents[model.selectedAgent])
	}

	deleteSelected := protocol.CollectionDelta{
		Collection: "agents", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 1, Done: true,
		Operations: []protocol.CollectionOperation{{Op: "delete", ID: "selected"}},
	}
	model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, deleteSelected)})
	if len(model.snapshot.Agents) != 1 || model.snapshot.Agents[model.selectedAgent].ID != "other" {
		t.Fatalf("selected-agent delete did not fall back safely: %#v", model.snapshot.Agents)
	}
}

// Typing a slash must not surface a command list; the start screen takes prompts
// and targets only.
func TestSetupOffersNoSlashCommands(t *testing.T) {
	model := New(nil)
	model.width, model.height = 100, 50
	model.showSplash = false
	model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"}))

	model.input.SetValue("/")
	model.resizeViewport()
	view := ansi.Strip(model.View())
	for _, gone := range []string{"/target", "/start", "/clear", "/prompt", "/quit", "/help"} {
		if strings.Contains(view, gone) {
			t.Fatalf("a slash command menu still appears for %q: %s", gone, view)
		}
	}
}

func TestSetupUsesDedicatedStartScreen(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 34
	model.showSplash = false
	state := protocol.Snapshot{
		SetupMode:    true,
		ScanState:    "setup",
		Model:        "gpt-5.4",
		Targets:      []string{"/workspace/source", "https://example.com"},
		Instruction:  "focus on access control",
		ScanMode:     "quick",
		MaxBudgetUSD: floatPointer(12.5),
		MaxTurns:     275,
		ScopeMode:    "diff",
		DiffBase:     "origin/main",
		Agents:       []protocol.Agent{{ID: "hidden", Name: "SETUP_SHOULD_HIDE_AGENT", Status: "running"}},
	}
	model.handleEnvelope(stateEnvelope(t, 1, state))

	view := model.View()
	for _, want := range []string{
		"gpt-5.4",
		"/workspace/source",
		"https://example.com",
	} {
		if !strings.Contains(view, want) {
			t.Fatalf("start screen is missing %q: %s", want, view)
		}
	}
	if strings.Contains(view, "SETUP_SHOULD_HIDE_AGENT") {
		t.Fatalf("live scan sidebar appeared on the start screen: %s", view)
	}
}

func floatPointer(value float64) *float64 { return &value }

func TestStartedSnapshotTransitionsToLiveView(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 34
	model.showSplash = false
	model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"}))
	if view := model.View(); !strings.Contains(view, setupPlaceholder) {
		t.Fatalf("setup snapshot did not show the start screen: %s", view)
	}

	runningState := protocol.Snapshot{
		SetupMode:   false,
		ScanStarted: true,
		ScanState:   "running",
	}
	model.handleEnvelope(stateEnvelope(t, 2, runningState))
	model.handleEnvelope(bootstrapEnvelope(t, "agents", 1, protocol.Agent{ID: "one", Name: "LIVE_AGENT", Status: "running"}))
	view := model.View()
	if !strings.Contains(view, "LIVE_AGENT") || !strings.Contains(view, "Send a message") || strings.Contains(view, "Configure your pentest") {
		t.Fatalf("started snapshot did not switch to the live view: %s", view)
	}
	if model.input.Placeholder != "Send a message" {
		t.Fatalf("live input placeholder was not updated: %q", model.input.Placeholder)
	}
}

func TestSetupStartScreenFitsNarrowTerminal(t *testing.T) {
	model := New(nil)
	model.width, model.height = 40, 18
	model.showSplash = false
	model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"}))
	model.input.SetValue("/")
	model.resizeViewport()

	view := ansi.Strip(model.viewInner())
	// A narrow terminal falls back to the plain wordmark, but the launch screen
	// never gives up its identity entirely.
	topRow := ansi.Strip(strings.SplitN(wordmark(), "\n", 2)[0])
	if !strings.Contains(view, topRow) && !strings.Contains(view, "STRIX") {
		t.Fatalf("narrow start screen logo is missing: %s", view)
	}
	lines := strings.Split(view, "\n")
	if len(lines) > model.height {
		t.Fatalf("start screen height %d exceeds terminal height %d", len(lines), model.height)
	}
	for _, line := range lines {
		if width := lipgloss.Width(line); width > model.width {
			t.Fatalf("start screen line width %d exceeds terminal width %d: %q", width, model.width, ansi.Strip(line))
		}
	}
}

// A leading slash is ordinary prompt text now: there are no commands to match,
// so it must reach the scan as written rather than being rejected.
func TestLeadingSlashIsPromptTextNotACommand(t *testing.T) {
	connection := &recordingConn{}
	model := New(&Client{conn: connection})
	model.snapshot = protocol.Snapshot{SetupMode: true}
	model.focus = focusInput
	model.input.SetValue("/etc/passwd is world readable, check it")

	updated, cmd := model.updateMain(tea.KeyMsg{Type: tea.KeyEnter})
	result := updated.(Model)
	if cmd == nil {
		t.Fatal("enter did not submit")
	}
	types := commandTypes(drainCommands(t, cmd, connection))
	if !contains(types, "setup.start") {
		t.Fatalf("a slash-leading prompt did not launch a scan: %v", types)
	}
	// The path is read as a target and the sentence as the instruction.
	if !contains(types, "setup.add_target") || !contains(types, "setup.set_instruction") {
		t.Fatalf("slash-leading prompt was not split into target and instruction: %v", types)
	}
	for _, line := range result.setupLog {
		if strings.Contains(ansi.Strip(line), "Unknown command") {
			t.Fatalf("a slash-leading prompt was treated as a command: %#v", result.setupLog)
		}
	}
}

// The composer is cleared on submit and the prompt is not echoed into the log.
func TestSubmittedPromptIsNotEchoedInOutput(t *testing.T) {
	model := New(nil)
	model.snapshot.SetupMode = true
	updated, _ := model.submitSetupPrompt("secret instruction")
	result := updated.(Model)
	if content := result.setupContent(); strings.Contains(content, "secret instruction") {
		t.Fatalf("submitted prompt leaked into output: %s", content)
	}
}

func TestStateMessagesRenderOnce(t *testing.T) {
	model := New(nil)
	message := protocol.Message{ID: "setup-1", Text: "Replace the rejected key", Level: "warning"}
	model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, Messages: []protocol.Message{message}}))
	model.handleEnvelope(stateEnvelope(t, 2, protocol.Snapshot{SetupMode: true, Messages: []protocol.Message{message}}))
	if len(model.setupLog) != 1 || !strings.Contains(ansi.Strip(model.setupLog[0]), message.Text) {
		t.Fatalf("setup message was not rendered exactly once: %#v", model.setupLog)
	}
}

func TestMouseActivatesQuitPromptButtons(t *testing.T) {
	model := New(nil)
	model.width, model.height = 100, 30
	model.modal = modalQuit
	view := model.modalView()
	left, top, _, _ := model.centeredViewBounds(view)

	buttonPosition := func(label string) (int, int) {
		t.Helper()
		for row, line := range strings.Split(view, "\n") {
			plain := ansi.Strip(line)
			if index := strings.Index(plain, label); index >= 0 {
				return left + ansi.StringWidth(plain[:index]), top + row
			}
		}
		t.Fatalf("button %q not found", label)
		return 0, 0
	}

	x, y := buttonPosition("No")
	updated, cmd := model.updateMouse(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
	result := updated.(Model)
	if result.modal != modalNone || result.quitting || cmd != nil {
		t.Fatalf("No did not dismiss quit prompt: modal=%v quitting=%v cmd=%v", result.modal, result.quitting, cmd)
	}

	model.modal = modalQuit
	view = model.modalView()
	left, top, _, _ = model.centeredViewBounds(view)
	x, y = buttonPosition("Yes")
	updated, cmd = model.updateMouse(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
	result = updated.(Model)
	if !result.quitting || cmd == nil {
		t.Fatalf("Yes did not confirm quit: quitting=%v cmd=%v", result.quitting, cmd)
	}
}

func TestModalKeepsBackgroundVisible(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	model.showSplash = false
	model.ready = true
	model.snapshot = protocol.Snapshot{Agents: []protocol.Agent{{ID: "one", Name: "UNIQUE_AGENT", Status: "running"}}}
	model.resizeViewport()
	model.modal = modalHelp
	view := model.View()
	if !strings.Contains(view, "UNIQUE_AGENT") {
		t.Fatalf("modal overlay hid the background agent tree")
	}
	if !strings.Contains(view, "Strix Help") {
		t.Fatalf("modal content missing")
	}
}

func TestChatWrapsWithinChatWidth(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	model.showSplash = false
	model.ready = true
	long := strings.Repeat("word ", 200)
	model.snapshot = protocol.Snapshot{
		Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}},
		Events: []protocol.Event{{ID: "1", AgentID: "one", Type: "chat", Data: map[string]any{"role": "assistant", "content": long}}},
	}
	model.resizeViewport()
	for _, line := range strings.Split(model.chatContent(), "\n") {
		if lipgloss.Width(line) > model.viewport.Width {
			t.Fatalf("chat line width %d exceeds viewport width %d", lipgloss.Width(line), model.viewport.Width)
		}
	}
}

func TestSnapshotRendersSelectedAgentEventsOnly(t *testing.T) {
	model := New(nil)
	model.width, model.height = 100, 30
	model.showSplash = false
	model.ready = true
	model.snapshot = protocol.Snapshot{
		SetupMode: false,
		Agents:    []protocol.Agent{{ID: "one", Name: "Agent One", Status: "running"}, {ID: "two", Name: "Agent Two", Status: "waiting"}},
		Events: []protocol.Event{
			{ID: "1", AgentID: "one", Type: "chat", Data: map[string]any{"role": "assistant", "content": "first"}},
			{ID: "2", AgentID: "two", Type: "chat", Data: map[string]any{"role": "assistant", "content": "second"}},
		},
	}
	model.refreshViewport()
	view := model.View()
	if !strings.Contains(view, "first") || strings.Contains(view, "second") {
		t.Fatalf("incorrect selected-agent events: %s", view)
	}
}

func TestVulnerabilityDetailScrollsWithoutHidingFooter(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 40
	model.snapshot.Vulnerabilities = []map[string]any{{
		"title":       "Long finding",
		"severity":    "high",
		"description": strings.Repeat("detail line\n", 100),
	}}
	model.openModal(modalVulnerability)

	view := model.modalView()
	if !strings.Contains(view, "Done") {
		t.Fatalf("finding footer is not visible before scrolling: %s", view)
	}
	_, wantHeight := model.vulnerabilityDialogSize()
	if got := len(strings.Split(view, "\n")); got != wantHeight {
		t.Fatalf("finding dialog height = %d, want %d", got, wantHeight)
	}

	updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyPgDown})
	model = updated.(Model)
	if model.modal != modalVulnerability || model.vulnViewport.YOffset == 0 {
		t.Fatalf("page down dismissed or did not scroll finding: modal=%v offset=%d", model.modal, model.vulnViewport.YOffset)
	}
	if view = model.modalView(); !strings.Contains(view, "Done") {
		t.Fatalf("finding footer disappeared after scrolling: %s", view)
	}

	before := model.vulnViewport.YOffset
	modalLeft, modalTop, _, _ := model.centeredViewBounds(model.modalView())
	updated, _ = model.updateModalMouse(tea.MouseMsg{X: modalLeft + 4, Y: modalTop + 3, Button: tea.MouseButtonWheelDown})
	model = updated.(Model)
	if model.vulnViewport.YOffset <= before {
		t.Fatalf("mouse wheel did not scroll finding: before=%d after=%d", before, model.vulnViewport.YOffset)
	}
	updated, _ = model.updateModal(tea.KeyMsg{Type: tea.KeyEsc})
	if updated.(Model).modal != modalNone {
		t.Fatal("escape did not close finding detail")
	}
}

func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
	originalWriteClipboard := writeClipboard
	t.Cleanup(func() { writeClipboard = originalWriteClipboard })
	var copied []string
	writeClipboard = func(value string) error {
		copied = append(copied, value)
		return nil
	}

	newModel := func() Model {
		model := New(nil)
		model.width, model.height = 130, 40
		model.snapshot.Vulnerabilities = []map[string]any{{
			"title": "Copy me", "severity": "high", "description": "Finding detail",
		}}
		model.openModal(modalVulnerability)
		return model
	}

	model := newModel()
	// Tab moves between the buttons; the arrows step between reports.
	updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
	model = updated.(Model)
	updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
	model = updated.(Model)
	if cmd == nil || model.modal != modalVulnerability {
		t.Fatalf("keyboard Copy did not keep the detail open: modal=%v cmd=%v", model.modal, cmd)
	}
	updated, _ = model.Update(cmd())
	model = updated.(Model)
	if len(copied) != 1 || !strings.Contains(copied[0], "Copy me") || !strings.Contains(copied[0], "Finding detail") {
		t.Fatalf("keyboard Copy wrote unexpected report: %#v", copied)
	}
	if !model.vulnerabilityCopied || !strings.Contains(ansi.Strip(model.modalView()), "Copied!") {
		t.Fatal("successful keyboard Copy was not reflected in the dialog")
	}

	model = newModel()
	view := model.modalView()
	left, top, _, _ := model.centeredViewBounds(view)
	copyX, copyY := -1, -1
	for row, line := range strings.Split(view, "\n") {
		plain := ansi.Strip(line)
		if index := strings.Index(plain, "Copy"); index >= 0 {
			copyX, copyY = left+ansi.StringWidth(plain[:index]), top+row
		}
	}
	if copyX < 0 {
		t.Fatal("Copy button was not rendered")
	}
	updated, cmd = model.updateModalMouse(tea.MouseMsg{
		X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
	})
	model = updated.(Model)
	if cmd == nil || model.reportFocus != reportCopy {
		t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
	}
	cmd()
	if len(copied) != 2 {
		t.Fatalf("mouse Copy calls = %d, want 2", len(copied))
	}
}

func TestVulnerabilitySelectionStaysVisible(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	model.focus = focusVulnerabilities
	for i := 0; i < 20; i++ {
		model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{
			"title": fmt.Sprintf("Finding %02d", i),
		})
	}
	for range 15 {
		updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyDown})
		model = updated.(Model)
	}

	view := ansi.Strip(model.vulnerabilitiesView(30, 10))
	if !strings.Contains(view, "Finding 15") || strings.Contains(view, "Finding 00") {
		t.Fatalf("selected finding was not kept in the visible window: %s", view)
	}
}

func TestVulnerabilityListSupportsWheelAndPageNavigation(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	for i := 0; i < 20; i++ {
		model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{
			"title": fmt.Sprintf("Finding %02d", i),
		})
	}
	_, _, chatWidth, _ := model.layout()
	_, _, agentHeight := model.sidebarHeights()
	pageItems := model.vulnerabilityPageItems()

	updated, _ := model.updateMouse(tea.MouseMsg{
		X: chatWidth + 2, Y: model.viewerHeight() + agentHeight + 1, Button: tea.MouseButtonWheelDown,
	})
	model = updated.(Model)
	if model.focus != focusVulnerabilities || model.vulnOffset != 3 || model.selectedVuln != 3 {
		t.Fatalf("wheel scroll did not focus and advance list: focus=%v offset=%d selected=%d", model.focus, model.vulnOffset, model.selectedVuln)
	}

	updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyPgDown})
	model = updated.(Model)
	if model.selectedVuln != 3+pageItems {
		t.Fatalf("page down selected %d", model.selectedVuln)
	}
	updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyEnd})
	model = updated.(Model)
	if model.selectedVuln != 19 {
		t.Fatalf("end selected %d, want 19", model.selectedVuln)
	}
	view := ansi.Strip(model.vulnerabilitiesView(30, model.vulnerabilityPageSize()))
	if !strings.Contains(view, "Finding 19") || strings.Contains(view, "Finding 00") {
		t.Fatalf("end did not scroll the final finding into view: %s", view)
	}
}

func TestAgentTreeWheelScrollSurvivesSnapshot(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	model.ready = true
	for i := 0; i < 40; i++ {
		model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{
			ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running",
		})
	}
	_, _, chatWidth, _ := model.layout()

	updated, _ := model.updateMouse(tea.MouseMsg{
		X: chatWidth + 2, Y: model.viewerHeight() + 2, Button: tea.MouseButtonWheelDown,
	})
	model = updated.(Model)
	if model.focus != focusAgents || model.agentOffset != 3 || model.selectedAgent != 3 {
		t.Fatalf("wheel scroll did not advance tree: focus=%v offset=%d selected=%d", model.focus, model.agentOffset, model.selectedAgent)
	}

	model.handleEnvelope(stateEnvelope(t, 1, model.snapshot))
	if model.agentOffset != 3 || model.selectedAgent != 3 {
		t.Fatalf("snapshot reset manual tree scroll: offset=%d selected=%d", model.agentOffset, model.selectedAgent)
	}
}

func TestVulnerabilityDetailFitsNarrowTerminal(t *testing.T) {
	model := New(nil)
	model.width, model.height = 32, 15
	model.snapshot.Vulnerabilities = []map[string]any{{
		"title":       "Narrow finding",
		"description": strings.Repeat("long detail ", 50),
	}}
	model.openModal(modalVulnerability)
	view := model.modalView()

	if !strings.Contains(view, "Done") {
		t.Fatalf("finding footer is missing in narrow terminal: %s", view)
	}
	if got := len(strings.Split(view, "\n")); got > model.height {
		t.Fatalf("finding dialog height %d exceeds terminal height %d", got, model.height)
	}
	for _, line := range strings.Split(view, "\n") {
		if got := lipgloss.Width(line); got > model.width {
			t.Fatalf("finding dialog width %d exceeds terminal width %d", got, model.width)
		}
	}
}

func TestAgentTreeUsesDepthFirstOrderAndStableSelectionPosition(t *testing.T) {
	parentRoot := "root"
	parentA := "a"
	model := New(nil)
	model.snapshot.Agents = []protocol.Agent{
		{ID: "root", Name: "Root", Status: "running"},
		{ID: "a", ParentID: &parentRoot, Name: "Agent A", Status: "running"},
		{ID: "b", ParentID: &parentRoot, Name: "Agent B", Status: "running"},
		{ID: "a-child", ParentID: &parentA, Name: "Agent A Child", Status: "running"},
	}

	entries := agentTreeEntries(model.snapshot.Agents, nil)
	var order []string
	for _, entry := range entries {
		order = append(order, model.snapshot.Agents[entry.index].ID)
	}
	if got, want := strings.Join(order, ","), "root,a,a-child,b"; got != want {
		t.Fatalf("agent tree order = %q, want %q", got, want)
	}
	if view := ansi.Strip(model.agentsView(50, 10)); !strings.Contains(view, "▼") {
		t.Fatalf("expanded agent does not show its toggle: %s", view)
	}
	// A leaf carries no toggle at all, so its icon sits where a parent's
	// toggle would be.
	for _, line := range strings.Split(ansi.Strip(model.agentsView(50, 10)), "\n") {
		if strings.Contains(line, "Agent B") && !strings.HasSuffix(line, "└─ ⚪ Agent B") {
			t.Fatalf("leaf row reserved toggle space: %q", line)
		}
	}

	model.selectedAgent = 1
	selectedView := ansi.Strip(model.agentsView(50, 10))
	model.selectedAgent = 2
	unselectedView := ansi.Strip(model.agentsView(50, 10))
	lineFor := func(view, label string) string {
		t.Helper()
		for _, line := range strings.Split(view, "\n") {
			if strings.Contains(line, label) {
				return line
			}
		}
		t.Fatalf("agent row %q not found in %s", label, view)
		return ""
	}
	if selected, unselected := strings.Index(lineFor(selectedView, "Agent A"), "Agent A"), strings.Index(lineFor(unselectedView, "Agent A"), "Agent A"); selected != unselected {
		t.Fatalf("selection moved agent label from column %d to %d", unselected, selected)
	}

	// The cursor is a filled block behind the label; no row carries a gutter
	// accent, which would indent every node past the panel padding.
	if strings.ContainsAny(selectedView, "┃") {
		t.Fatalf("agent rows drew a gutter accent: %s", selectedView)
	}

	model.focus = focusAgents
	model.selectedAgent = 1
	updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyDown})
	result := updated.(Model)
	if got := result.snapshot.Agents[result.selectedAgent].ID; got != "a-child" {
		t.Fatalf("down selected %q, want depth-first child", got)
	}

	updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyEnter})
	result = updated.(Model)
	collapsed := agentTreeEntries(result.snapshot.Agents, result.collapsedAgents)
	for _, entry := range collapsed {
		if result.snapshot.Agents[entry.index].ID == "a-child" {
			t.Fatal("collapsed parent still rendered its child")
		}
	}
	if view := ansi.Strip(result.agentsView(50, 10)); !strings.Contains(view, "▶") {
		t.Fatalf("collapsed agent does not show its toggle: %s", view)
	}
}

func TestAgentClickUsesRenderedWindowAfterResize(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 20
	for i := range 20 {
		model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{
			ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running",
		})
	}
	model.selectedAgent = 15
	model.ensureAgentVisible()
	if model.agentOffset == 0 {
		t.Fatal("test setup did not scroll the agent tree")
	}

	model.height = 60
	model.ensureAgentVisible()
	_, _, chatWidth, _ := model.layout()
	updated, _ := model.updateMouse(tea.MouseMsg{X: chatWidth + 1, Y: model.viewerHeight() + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
	result := updated.(Model)
	if result.selectedAgent != 0 {
		t.Fatalf("click selected snapshot index %d instead of first rendered agent", result.selectedAgent)
	}
}

func TestFindingTitlesWrapAndViewerCTAIsClickable(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	model.snapshot.Vulnerabilities = []map[string]any{{
		"title": "A finding title that is intentionally long enough to wrap onto multiple lines",
	}}

	view := ansi.Strip(model.vulnerabilitiesView(model.vulnerabilityListWidth(), 10))
	if strings.Count(view, "\n") < 1 || strings.Contains(view, "…") {
		t.Fatalf("finding title was not wrapped: %s", view)
	}
	if cta := ansi.Strip(model.viewerView(40)); !strings.Contains(cta, "Watch live in browser") {
		t.Fatalf("viewer CTA is missing: %s", cta)
	}

	_, _, chatWidth, _ := model.layout()
	_, cmd := model.updateMouse(tea.MouseMsg{
		X: chatWidth + 2, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
	})
	if cmd == nil {
		t.Fatal("clicking viewer CTA did not send viewer.open")
	}
}

func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
	model := New(nil)
	model.width, model.height = 130, 30
	url := "http://127.0.0.1:43123/?token=abcdefghijklmnopqrstuvwxyz0123456789"
	model.snapshot.ViewerStatus = "running"
	model.snapshot.ViewerURL = &url

	view := ansi.Strip(model.viewerView(18))
	if !strings.Contains(view, "Viewer running") {
		t.Fatalf("viewer status is missing: %s", view)
	}
	urlLines := strings.Split(strings.SplitN(view, "\n", 2)[1], "\n")
	for i := range urlLines {
		urlLines[i] = strings.TrimRight(urlLines[i], " ")
	}
	if got := strings.Join(urlLines, ""); got != url {
		t.Fatalf("wrapped viewer URL = %q, want %q", got, url)
	}
	if want := strings.Count(model.viewerView(model.viewerContentWidth()), "\n") + 3; model.viewerHeight() != want {
		t.Fatalf("viewer height = %d, want %d", model.viewerHeight(), want)
	}
}

func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
	top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
	bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")

	// The track is blank, so only the thumb is drawn.
	if top[0] != "█" || top[5] != " " {
		t.Fatalf("top scrollbar is incorrect: %#v", top)
	}
	if bottom[0] != " " || bottom[5] != "█" {
		t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
	}
	if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" {
		t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
	}
	withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
	if strings.ContainsAny(withoutBar, "█") {
		t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
	}
}

// The bar takes exactly one column, so a scrolling panel keeps the rest.
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
	rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n")
	for _, row := range rows {
		if width := ansi.StringWidth(row); width != 12 {
			t.Fatalf("scrolling panel row width = %d, want 12", width)
		}
	}
	if !strings.Contains(ansi.Strip(rows[0]), "█") {
		t.Fatalf("thumb missing from the first row: %q", rows[0])
	}
}

func TestPanelPaddingResetsLeakingLineBackground(t *testing.T) {
	leaky := "\x1b[48;2;82;82;82mstyled"
	body := fixedPanelBody(leaky, 12, 1)
	want := "styled\x1b[0m" + blackBG
	if !strings.Contains(body, want) {
		t.Fatalf("panel padding did not reset the source background: %q", body)
	}
	if width := ansi.StringWidth(body); width != 12 {
		t.Fatalf("fixed panel body width = %d, want 12", width)
	}
}

func TestMainTraceTreeAndFindingsRenderScrollbars(t *testing.T) {
	model := New(nil)
	model.width, model.height = 150, 35
	model.ready = true
	for i := 0; i < 40; i++ {
		model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{
			ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running",
		})
	}
	for i := 0; i < 20; i++ {
		model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{
			"title": fmt.Sprintf("Finding %02d", i),
		})
	}
	model.resizeViewport()
	model.viewportContent = strings.Repeat("trace line\n", 100)
	model.viewport.SetContent(model.viewportContent)
	model.viewport.SetYOffset(10)

	view := ansi.Strip(model.mainView())
	if count := strings.Count(view, "█"); count < 3 {
		t.Fatalf("expected scroll thumbs in trace, tree, and findings; found %d\n%s", count, view)
	}
}

func TestMainScrollbarsSupportClickAndDrag(t *testing.T) {
	model := New(nil)
	model.width, model.height = 150, 35
	model.ready = true
	for i := 0; i < 40; i++ {
		model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{
			ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running",
		})
	}
	for i := 0; i < 20; i++ {
		model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{
			"title": fmt.Sprintf("Finding %02d", i),
		})
	}
	model.resizeViewport()
	model.viewportContent = strings.Repeat("trace line\n", 100)
	model.viewport.SetContent(model.viewportContent)
	showSidebar, _, chatWidth, chatHeight := model.layout()
	viewerHeight := model.viewerHeight()
	_, vulnHeight, agentHeight := model.sidebarHeights()
	if !showSidebar {
		t.Fatal("test requires sidebar")
	}

	updated, _ := model.updateMouse(tea.MouseMsg{
		X: chatWidth - 2, Y: chatHeight - 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
	})
	model = updated.(Model)
	if model.draggingScrollbar != scrollbarTrace || model.viewport.YOffset == 0 {
		t.Fatalf("trace scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.viewport.YOffset)
	}
	updated, _ = model.updateMouse(tea.MouseMsg{X: chatWidth - 2, Y: 1, Action: tea.MouseActionMotion})
	model = updated.(Model)
	if model.viewport.YOffset != 0 {
		t.Fatalf("trace scrollbar drag did not reach top: %d", model.viewport.YOffset)
	}
	updated, _ = model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease})
	model = updated.(Model)
	if model.draggingScrollbar != scrollbarNone {
		t.Fatal("trace scrollbar remained captured after release")
	}

	updated, _ = model.updateMouse(tea.MouseMsg{
		X: model.width - 3, Y: viewerHeight + agentHeight - 3,
		Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
	})
	model = updated.(Model)
	if model.draggingScrollbar != scrollbarAgents || model.agentOffset == 0 {
		t.Fatalf("agent scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.agentOffset)
	}
	updated, _ = model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease})
	model = updated.(Model)

	updated, _ = model.updateMouse(tea.MouseMsg{
		X: model.width - 3, Y: viewerHeight + agentHeight + vulnHeight - 2,
		Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
	})
	model = updated.(Model)
	if model.draggingScrollbar != scrollbarFindings || model.vulnOffset == 0 {
		t.Fatalf("findings scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.vulnOffset)
	}
}

func TestTerminalSnapshotWithoutAgentsDoesNotKeepLoading(t *testing.T) {
	tests := []struct {
		state string
		error string
		want  string
	}{
		{state: "failed", error: "authentication rejected", want: "Scan failed"},
		{state: "stopped", want: "Scan stopped"},
		{state: "completed", want: "Scan completed"},
		{state: "preparing", want: "Preparing scan..."},
	}

	for _, tt := range tests {
		t.Run(tt.state, func(t *testing.T) {
			model := New(nil)
			model.viewport.Width, model.viewport.Height = 80, 20
			model.snapshot.ScanState = tt.state
			if tt.error != "" {
				model.snapshot.Error = &tt.error
			}

			content := model.chatContent()
			if !strings.Contains(content, tt.want) || strings.Contains(content, "Loading...") {
				t.Fatalf("terminal state rendered incorrectly: %s", content)
			}
			if tt.error != "" && !strings.Contains(content, tt.error) {
				t.Fatalf("failure detail was not rendered: %s", content)
			}
		})
	}
}

func TestCrashedAndBudgetPausedAgentStatusParity(t *testing.T) {
	model := New(nil)
	model.width = 100
	model.snapshot.Agents = []protocol.Agent{
		{ID: "crashed", Name: "Crashed agent", Status: "crashed", ErrorMessage: "provider failed"},
		{ID: "paused", Name: "Paused agent", Status: "budget_paused"},
	}

	tree := ansi.Strip(model.agentsView(50, 10))
	if !strings.Contains(tree, "🔴 Crashed agent") || !strings.Contains(tree, "⏸ Paused agent") {
		t.Fatalf("agent status icons do not match Textual: %s", tree)
	}
	crashed := ansi.Strip(model.statusView(100))
	if !strings.Contains(crashed, "provider failed") || !strings.Contains(crashed, "Send message to resume") {
		t.Fatalf("crashed status lacks recovery guidance: %s", crashed)
	}
	model.selectedAgent = 1
	paused := ansi.Strip(model.statusView(100))
	if !strings.Contains(paused, "Budget limit reached") || !strings.Contains(paused, "Send a message to continue") || !strings.Contains(paused, "ctrl-q") {
		t.Fatalf("budget-paused status lacks Textual guidance: %s", paused)
	}
}

func TestStopDialogAndCommandAreLimitedToActiveAgents(t *testing.T) {
	tests := []struct {
		status string
		active bool
	}{
		{status: "running", active: true},
		{status: "waiting", active: true},
		{status: "budget_paused", active: true},
		{status: "completed"},
		{status: "failed"},
		{status: "crashed"},
		{status: "stopped"},
	}
	for _, tt := range tests {
		t.Run(tt.status, func(t *testing.T) {
			model := New(nil)
			model.snapshot.Agents = []protocol.Agent{{ID: "agent", Name: "Agent", Status: tt.status}}
			updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyEsc})
			result := updated.(Model)
			if got := result.modal == modalStop; got != tt.active {
				t.Fatalf("stop dialog shown=%v, want %v", got, tt.active)
			}
		})
	}

	model, connection := newCommandTestModel(t)
	model.snapshot.Agents = []protocol.Agent{{ID: "agent", Name: "Agent", Status: "running"}}
	model.modal, model.modalChoice = modalStop, 0
	model.snapshot.Agents[0].Status = "completed"
	updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
	if cmd != nil || updated.(Model).modal != modalNone || connection.Len() != 0 {
		t.Fatal("terminal status submitted a stale agent.stop command")
	}
}

func TestBudgetPauseShowsOneWarningToastUntilResumed(t *testing.T) {
	model := New(nil)
	model.snapshot.Agents = []protocol.Agent{{ID: "root", Name: "Strix", Status: "budget_paused"}}
	if cmd := model.notifyBudgetPause(); cmd == nil {
		t.Fatal("expected a toast command on first budget pause")
	}
	if !strings.Contains(model.toast, "Budget limit reached") {
		t.Fatalf("toast %q missing budget warning", model.toast)
	}
	if cmd := model.notifyBudgetPause(); cmd != nil {
		t.Fatal("budget toast should fire once per pause")
	}
	model.snapshot.Agents[0].Status = "running"
	if cmd := model.notifyBudgetPause(); cmd != nil {
		t.Fatal("no toast expected while running")
	}
	model.snapshot.Agents[0].Status = "budget_paused"
	if cmd := model.notifyBudgetPause(); cmd == nil {
		t.Fatal("expected the toast to re-arm after resuming")
	}
}

func TestStatsViewShowsSubscription(t *testing.T) {
	model := New(nil)
	model.snapshot.Model = "gpt-5"
	model.snapshot.Subscription = true
	model.snapshot.Usage = map[string]any{"total_tokens": float64(1200), "cost": 3.5}
	stats := ansi.Strip(model.statsView())
	if !strings.Contains(stats, "ChatGPT subscription") {
		t.Fatalf("stats missing subscription line: %q", stats)
	}
	if strings.Contains(stats, "$") {
		t.Fatalf("subscription runs must not show a cost: %q", stats)
	}
}

func TestVulnerabilityMarkdownReport(t *testing.T) {
	report := vulnerabilityMarkdownReport(map[string]any{
		"title":             "SQLi in login",
		"severity":          "high",
		"cvss":              8.1,
		"description":       "Injectable parameter.",
		"poc_script_code":   "```python\nprint('x')\n```",
		"remediation_steps": "Use bound parameters.",
	})
	for _, want := range []string{
		"# SQLi in login", "**Severity:** HIGH", "**CVSS:** 8.1",
		"## Description", "```python\nprint('x')\n```", "## Remediation",
	} {
		if !strings.Contains(report, want) {
			t.Fatalf("report missing %q:\n%s", want, report)
		}
	}
}

func TestChatContentCachesBlocksUntilEventChanges(t *testing.T) {
	model := New(nil)
	model.width, model.height = 120, 30
	model.showSplash = false
	model.ready = true
	event := protocol.Event{
		ID: "1", AgentID: "one", Type: "tool", Version: 1,
		Data: map[string]any{
			"tool_name": "exec_command",
			"args":      map[string]any{"cmd": "ls -la"},
			"result":    "one\ntwo",
			"status":    "completed",
		},
	}
	model.snapshot = protocol.Snapshot{
		Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}},
		Events: []protocol.Event{event},
	}
	model.resizeViewport()
	first := model.chatContent()
	if model.chatContent() != first {
		t.Fatal("cached render changed without an event change")
	}

	updated := event
	updated.Version = 2
	updated.Data = map[string]any{
		"tool_name": "exec_command",
		"args":      map[string]any{"cmd": "whoami"},
		"result":    "root",
		"status":    "completed",
	}
	model.snapshot.Events = []protocol.Event{updated}
	next := model.chatContent()
	if !strings.Contains(ansi.Strip(next), "whoami") {
		t.Fatalf("new event version was served from cache: %q", ansi.Strip(next))
	}
}

func TestChatContentRerendersOnWidthAndExpansionChange(t *testing.T) {
	model := New(nil)
	model.width, model.height = 120, 30
	model.showSplash = false
	model.ready = true
	model.snapshot = protocol.Snapshot{
		Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}},
		Events: []protocol.Event{{
			ID: "1", AgentID: "one", Type: "tool", Version: 1,
			Data: map[string]any{
				"tool_name": "exec_command",
				"args":      map[string]any{"cmd": "seq 40"},
				"result":    strings.Repeat("output line\n", 40),
				"status":    "completed",
			},
		}},
	}
	model.resizeViewport()
	collapsed := model.chatContent()
	model.expandedEvents["1"] = true
	expanded := model.chatContent()
	if strings.Count(expanded, "\n") <= strings.Count(collapsed, "\n") {
		t.Fatal("expanding an event was served from cache")
	}
	model.width = 80
	model.resizeViewport()
	narrow := model.chatContent()
	for _, line := range strings.Split(narrow, "\n") {
		if lipgloss.Width(line) > model.viewport.Width {
			t.Fatalf("stale wrapped width after resize: %d > %d", lipgloss.Width(line), model.viewport.Width)
		}
	}
}

// A model or backend failure can be a wrapped exception hundreds of columns
// wide and several lines long. The status row is one line of the chat column, so
// an oversized one widens the whole column - JoinHorizontal pads every row to the
// widest - which pushed the sidebar off screen and wrapped the frame.
func TestLongErrorDoesNotBreakTheFrame(t *testing.T) {
	model := New(nil)
	model.width, model.height = 120, 24
	model.showSplash = false
	model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
	bootstrap := protocol.CollectionBootstrap{
		Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true,
		Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})},
	}
	model.handleEnvelope(protocol.Envelope{
		Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap),
	})
	model.errorText = "litellm.APIConnectionError: OpenrouterException - Connection error " +
		"while calling https://openrouter.ai/api/v1/chat/completions: HTTPSConnectionPool" +
		"(host='openrouter.ai', port=443): Max retries exceeded\nTraceback (most recent " +
		"call last):\n  File \"/x/y.py\", line 42, in send\n    raise err"
	model.resizeViewport()

	lines := strings.Split(model.View(), "\n")
	if len(lines) > model.height {
		t.Fatalf("frame is %d rows in a %d-row terminal", len(lines), model.height)
	}
	for i, line := range lines {
		if width := ansi.StringWidth(line); width > model.width {
			t.Fatalf("row %d is %d columns in a %d-column terminal", i, width, model.width)
		}
	}
	// The sidebar has to survive: its panels are the right edge of the frame.
	if !strings.Contains(ansi.Strip(model.View()), "Strix") {
		t.Fatal("the agent tree was pushed out of the frame")
	}
}

func TestStatusMessageFlattensAndKeepsItsHint(t *testing.T) {
	row := ansi.Strip(statusMessage("boom\nsecond line\twith tabs", red, " · Send message to resume", 60))

	if strings.Contains(row, "\n") || strings.Contains(row, "\t") {
		t.Fatalf("status row is not a single line: %q", row)
	}
	if !strings.HasSuffix(row, " · Send message to resume") {
		t.Fatalf("the hint was lost: %q", row)
	}
	if !strings.Contains(row, "boom second line with tabs") {
		t.Fatalf("the message was mangled: %q", row)
	}
	// A message far too long for the row keeps the hint readable.
	long := ansi.Strip(statusMessage(strings.Repeat("x", 500), red, " · Send message to resume", 60))
	if width := ansi.StringWidth(long); width > 60 {
		t.Fatalf("status message is %d columns, want at most 60", width)
	}
	if !strings.HasSuffix(long, " · Send message to resume") {
		t.Fatalf("the hint was clipped away: %q", long)
	}
}

// The status row must be exactly as wide as the column it sits in, at every
// terminal size. A narrow terminal cannot fit the quit hint alongside any status
// text, and keeping it anyway made the row wider than the terminal.
func TestStatusRowIsExactlyItsWidth(t *testing.T) {
	quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") +
		lipgloss.NewStyle().Foreground(dim).Render(" quit")
	longMessage := lipgloss.NewStyle().Foreground(red).Render(strings.Repeat("boom ", 40))

	for width := 1; width <= 60; width++ {
		for _, testCase := range []struct {
			name        string
			left, right string
		}{
			{"empty", "", ""},
			{"hint only", "", quitHint},
			{"long message and hint", longMessage, quitHint},
			{"long message alone", longMessage, ""},
		} {
			row := composeStatusRow(testCase.left, testCase.right, width)
			if got := ansi.StringWidth(row); got != width {
				t.Fatalf("%s at width %d rendered %d columns: %q",
					testCase.name, width, got, ansi.Strip(row))
			}
			if strings.Contains(row, "\n") {
				t.Fatalf("%s at width %d spans rows", testCase.name, width)
			}
		}
	}
	if row := composeStatusRow("x", "y", 0); row != "" {
		t.Fatalf("a zero-width row should be empty, got %q", row)
	}
}

// A running scan in a narrow terminal must not wrap the frame.
func TestNarrowTerminalKeepsTheFrameIntact(t *testing.T) {
	for _, width := range []int{8, 10, 13, 14, 20, 40} {
		model := New(nil)
		model.width, model.height = width, 20
		model.showSplash = false
		model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
		bootstrap := protocol.CollectionBootstrap{
			Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true,
			Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})},
		}
		model.handleEnvelope(protocol.Envelope{
			Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap),
		})
		model.errorText = strings.Repeat("connection failed ", 20)
		model.resizeViewport()

		for i, line := range strings.Split(model.View(), "\n") {
			if got := ansi.StringWidth(line); got > width {
				t.Fatalf("at width %d row %d is %d columns", width, i, got)
			}
		}
	}
}
