package tui

import (
	"strings"
	"testing"
	"time"

	"github.com/smallnest/pigo/internal/cli/ui"
)

// TestSubagentPanelLifecycle exercises the ordered add/update/remove set: rows
// keep insertion order, a progress refreshes activity/tokens, a progress for an
// unknown id adds a row (late/out-of-order safe), and remove drops exactly one
// row (a no-op for an absent id).
func TestSubagentPanelLifecycle(t *testing.T) {
	now := time.Now()
	var p subagentPanel

	p.add("a", "task A", now)
	p.add("b", "task B", now)
	if got := p.active(); got != 2 {
		t.Fatalf("active after two adds = %d, want 2", got)
	}
	if p.order[0] != "a" || p.order[1] != "b" {
		t.Errorf("order = %v, want [a b]", p.order)
	}

	p.update("a", "task A", "Editing", 120, now)
	if row := p.byID["a"]; row.activity != "Editing" || row.tokens != 120 {
		t.Errorf("row a after update = %+v, want activity=Editing tokens=120", row)
	}

	// A progress for an id that never started adds the row (SPEC 5.4).
	p.update("c", "task C", "Reading", 0, now)
	if got := p.active(); got != 3 {
		t.Fatalf("active after late progress = %d, want 3", got)
	}
	if p.order[2] != "c" {
		t.Errorf("order = %v, want c appended last", p.order)
	}

	// Removing a middle row preserves the order of the rest.
	p.remove("a")
	if got := p.active(); got != 2 {
		t.Fatalf("active after remove = %d, want 2", got)
	}
	if p.order[0] != "b" || p.order[1] != "c" {
		t.Errorf("order after remove(a) = %v, want [b c]", p.order)
	}

	// Removing an absent id is a no-op.
	p.remove("zzz")
	if got := p.active(); got != 2 {
		t.Errorf("active after remove(absent) = %d, want 2", got)
	}
}

// TestSubagentPanelEmptyView verifies an empty panel renders nothing — zero
// lines, zero height — so the single-run layout is untouched.
func TestSubagentPanelEmptyView(t *testing.T) {
	var p subagentPanel
	if got := p.view(DefaultTheme(), 80, time.Now()); got != "" {
		t.Errorf("empty panel view = %q, want empty", got)
	}
	// A non-empty panel with a non-positive width also renders nothing.
	p.add("a", "task", time.Now())
	if got := p.view(DefaultTheme(), 0, time.Now()); got != "" {
		t.Errorf("zero-width view = %q, want empty", got)
	}
}

// TestSubagentPanelViewLines verifies one line per active sub-agent, each
// carrying the description, activity, and token stat.
func TestSubagentPanelViewLines(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "build parser", now)
	p.update("a", "build parser", "Editing", 1200, now)
	p.add("b", "run tests", now)
	p.update("b", "run tests", "Running bash", 0, now)

	view := p.view(DefaultTheme(), 200, now.Add(65*time.Second))
	lines := strings.Split(view, "\n")
	if len(lines) != 2 {
		t.Fatalf("view has %d lines, want 2: %q", len(lines), view)
	}
	if !strings.Contains(lines[0], "build parser") || !strings.Contains(lines[0], "Editing") {
		t.Errorf("line[0] = %q, want desc + activity", lines[0])
	}
	if !strings.Contains(lines[0], "1m 5s") {
		t.Errorf("line[0] = %q, want elapsed 1m 5s", lines[0])
	}
	if !strings.Contains(lines[0], "1,200") {
		t.Errorf("line[0] = %q, want token stat 1,200", lines[0])
	}
	if !strings.Contains(lines[1], "run tests") || !strings.Contains(lines[1], "Running bash") {
		t.Errorf("line[1] = %q, want desc + activity", lines[1])
	}
	// A zero token estimate omits the ↓ stat.
	if strings.Contains(lines[1], "↓") {
		t.Errorf("line[1] = %q, should omit ↓ for zero tokens", lines[1])
	}
}

// TestSubagentPanelViewTruncation verifies each rendered line is clipped to the
// given terminal width (display columns), never exceeding it.
func TestSubagentPanelViewTruncation(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", strings.Repeat("very long description ", 10), now)
	p.update("a", "", "Searching", 42, now)

	const width = 30
	view := p.view(DefaultTheme(), width, now)
	for _, line := range strings.Split(view, "\n") {
		if w := ui.Width(line); w > width {
			t.Errorf("line width = %d, want <= %d: %q", w, width, line)
		}
	}
}

// TestSubagentPanelViewBlankDescription verifies a row with no description still
// renders (leading with the activity) rather than producing a dangling "·".
func TestSubagentPanelViewBlankDescription(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "", now)
	p.update("a", "", "Thinking", 0, now)
	line := p.view(DefaultTheme(), 100, now)
	if !strings.Contains(line, "Thinking") {
		t.Errorf("view = %q, want activity", line)
	}
	if strings.Contains(line, " · Thinking") {
		t.Errorf("view = %q, blank desc should not leave a leading ' · '", line)
	}
}

// TestSubagentPanelSelection verifies the cursor navigation: a fresh panel has no
// selection, the first ↓ lands on the top row and the first ↑ on the bottom row,
// movement clamps at both ends, and clearSelection resets to no-cursor state.
func TestSubagentPanelSelection(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "task A", now)
	p.add("b", "task B", now)
	p.add("c", "task C", now)

	if p.hasSelection() {
		t.Fatal("fresh panel should have no selection")
	}

	// First ↓ selects the top row; further ↓ advance and clamp at the bottom.
	p.selectDown()
	if !p.hasSelection() || p.selected != 0 {
		t.Fatalf("after first down: hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
	}
	p.selectDown()
	p.selectDown()
	p.selectDown() // clamp
	if p.selected != 2 {
		t.Errorf("selected after clamp down = %d, want 2", p.selected)
	}

	// ↑ retreats and clamps at the top.
	p.selectUp()
	if p.selected != 1 {
		t.Errorf("selected after up = %d, want 1", p.selected)
	}
	p.selectUp()
	p.selectUp() // clamp
	if p.selected != 0 {
		t.Errorf("selected after clamp up = %d, want 0", p.selected)
	}

	p.clearSelection()
	if p.hasSelection() {
		t.Error("clearSelection should drop the cursor")
	}

	// From no selection, the first ↑ lands on the bottom row.
	p.selectUp()
	if !p.hasSelection() || p.selected != 2 {
		t.Errorf("first up from none: selected=%d, want 2", p.selected)
	}
}

// TestSubagentPanelExpandView verifies that expanding a selected row appends its
// accumulated output below the status line, that the cursor marker is present,
// that lineCount matches the rendered height, and that collapsing removes the
// extra lines.
func TestSubagentPanelExpandView(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "task A", now)
	p.add("b", "task B", now)
	p.appendOutput("b", "hello from B\nsecond line")

	p.selectDown() // selects "a"
	p.selectDown() // selects "b"
	if id := p.expandedID(); id != "" {
		t.Errorf("expandedID before toggle = %q, want empty", id)
	}
	p.toggleExpand()
	if id := p.expandedID(); id != "b" {
		t.Errorf("expandedID after toggle = %q, want b", id)
	}

	const width = 80
	view := p.view(DefaultTheme(), width, now)
	lines := strings.Split(view, "\n")
	if got := p.lineCount(width); got != len(lines) {
		t.Errorf("lineCount = %d, rendered %d lines", got, len(lines))
	}
	// Two status rows + two output lines.
	if len(lines) != 4 {
		t.Fatalf("expanded view has %d lines, want 4: %q", len(lines), view)
	}
	if !strings.Contains(view, "❯") {
		t.Errorf("expanded view missing selection cursor: %q", view)
	}
	if !strings.Contains(view, "hello from B") || !strings.Contains(view, "second line") {
		t.Errorf("expanded view missing output: %q", view)
	}

	// Collapsing reclaims the output lines.
	p.toggleExpand()
	if got := p.lineCount(width); got != 2 {
		t.Errorf("lineCount after collapse = %d, want 2", got)
	}
}

// TestSubagentPanelExpandTruncates verifies the inline output is wrapped and
// tail-capped: every rendered line fits the width and no more than
// maxExpandedLines output lines are shown.
func TestSubagentPanelExpandTruncates(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "task A", now)
	// Many short lines exceed the cap; one very long line must wrap.
	p.appendOutput("a", strings.Repeat("x\n", 40))
	p.appendOutput("a", strings.Repeat("y", 500))
	p.selectDown()
	p.toggleExpand()

	const width = 40
	view := p.view(DefaultTheme(), width, now)
	lines := strings.Split(view, "\n")
	for _, line := range lines {
		if w := ui.Width(line); w > width {
			t.Errorf("line width = %d, want <= %d: %q", w, width, line)
		}
	}
	// 1 status row + at most maxExpandedLines output lines.
	if len(lines) > 1+maxExpandedLines {
		t.Errorf("expanded view has %d lines, want <= %d", len(lines), 1+maxExpandedLines)
	}
}

// TestSubagentPanelRemoveClampsSelection verifies that removing rows keeps the
// selection valid: removing the selected bottom row clamps the cursor to the new
// last row, and removing the final row clears the selection entirely.
func TestSubagentPanelRemoveClampsSelection(t *testing.T) {
	now := time.Now()
	var p subagentPanel
	p.add("a", "task A", now)
	p.add("b", "task B", now)
	p.selectDown()
	p.selectDown() // selects "b" (index 1)

	p.remove("b")
	if !p.hasSelection() || p.selected != 0 {
		t.Errorf("after remove(b): hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
	}

	p.remove("a")
	if p.hasSelection() {
		t.Error("removing the last row should clear the selection")
	}
	if got := p.lineCount(80); got != 0 {
		t.Errorf("empty panel lineCount = %d, want 0", got)
	}
}

// TestSubagentPanelAppendOutputUnknown verifies deltas for an unknown id are
// dropped (no phantom row, no panic).
func TestSubagentPanelAppendOutputUnknown(t *testing.T) {
	var p subagentPanel
	p.appendOutput("ghost", "data") // must not panic or add a row
	if p.active() != 0 {
		t.Errorf("active after appendOutput to unknown id = %d, want 0", p.active())
	}
}
