package conversation

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/bjornjee/agent-dashboard/internal/domain"
)

func TestProjectSlug(t *testing.T) {
	tests := []struct {
		cwd  string
		want string
	}{
		{"/Users/bjornjee/Code/bjornjee/skills", "-Users-bjornjee-Code-bjornjee-skills"},
		{"/Users/bjornjee/Code/newb/ctf", "-Users-bjornjee-Code-newb-ctf"},
		{"/tmp/test", "-tmp-test"},
		{"/Users/bjornjee/.dotfiles/dotfiles", "-Users-bjornjee--dotfiles-dotfiles"},
		{"/Users/bjornjee/.claude", "-Users-bjornjee--claude"},
		{"/Users/bjornjee/.tmux/plugins/agent-dashboard", "-Users-bjornjee--tmux-plugins-agent-dashboard"},
		// Underscores in directory names must be replaced with hyphens
		// to match Claude Code's slug convention.
		{"/Users/bjornjee/Code/tomoro/project_venice", "-Users-bjornjee-Code-tomoro-project-venice"},
	}
	for _, tt := range tests {
		got := ProjectSlug(tt.cwd)
		if got != tt.want {
			t.Errorf("ProjectSlug(%q) = %q, want %q", tt.cwd, got, tt.want)
		}
	}
}

func TestReadConversation_MissingFile(t *testing.T) {
	entries := ReadConversation("/nonexistent", "no-such-id", 10)
	if len(entries) != 0 {
		t.Errorf("expected empty, got %d entries", len(entries))
	}
}

func TestReadConversation_ParsesEntries(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	jsonl := `{"type":"user","message":{"role":"user","content":"fix the bug"},"timestamp":"2026-03-28T10:15:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"let me think..."},{"type":"text","text":"I fixed the bug by updating the handler."}]},"timestamp":"2026-03-28T10:15:30Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]},"timestamp":"2026-03-28T10:15:35Z"}
{"type":"user","message":{"role":"user","content":"thanks!"},"timestamp":"2026-03-28T10:16:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"You're welcome!"}]},"timestamp":"2026-03-28T10:16:30Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)

	// Should have 4 entries: 2 user + 2 assistant text (skip tool_use-only entry)
	if len(entries) != 4 {
		t.Fatalf("expected 4 entries, got %d: %+v", len(entries), entries)
	}

	// First entry: user
	if entries[0].Role != "human" || entries[0].Content != "fix the bug" {
		t.Errorf("entry 0: got %+v", entries[0])
	}

	// Second entry: assistant (thinking stripped, only text)
	if entries[1].Role != "assistant" || entries[1].Content != "I fixed the bug by updating the handler." {
		t.Errorf("entry 1: got %+v", entries[1])
	}

	// Third: user
	if entries[2].Role != "human" || entries[2].Content != "thanks!" {
		t.Errorf("entry 2: got %+v", entries[2])
	}

	// Fourth: assistant
	if entries[3].Role != "assistant" || entries[3].Content != "You're welcome!" {
		t.Errorf("entry 3: got %+v", entries[3])
	}
}

func TestReadConversation_RespectsLimit(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	jsonl := `{"type":"user","message":{"role":"user","content":"msg1"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"reply1"}]},"timestamp":"2026-03-28T10:00:01Z"}
{"type":"user","message":{"role":"user","content":"msg2"},"timestamp":"2026-03-28T10:01:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"reply2"}]},"timestamp":"2026-03-28T10:01:01Z"}
{"type":"user","message":{"role":"user","content":"msg3"},"timestamp":"2026-03-28T10:02:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"reply3"}]},"timestamp":"2026-03-28T10:02:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 2)
	if len(entries) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(entries))
	}
	// Should be the LAST 2 entries
	if entries[0].Content != "msg3" {
		t.Errorf("expected msg3, got %s", entries[0].Content)
	}
	if entries[1].Content != "reply3" {
		t.Errorf("expected reply3, got %s", entries[1].Content)
	}
}

func TestReadConversation_HandlesUserContentArray(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	// User messages with tool_result content (array format) should be skipped
	jsonl := `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"tool output"}]}]},"timestamp":"2026-03-28T10:15:00Z"}
{"type":"user","message":{"role":"user","content":"actual user message"},"timestamp":"2026-03-28T10:16:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	// Only the string-content user message should appear
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry, got %d: %+v", len(entries), entries)
	}
	if entries[0].Content != "actual user message" {
		t.Errorf("expected 'actual user message', got %q", entries[0].Content)
	}
}

func TestReadConversation_HandlesMixedArrayUserContent(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	// Plan mode response: array with tool_result + text block.
	// The text block contains the user's actual feedback and must be captured.
	jsonl := `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"tool output"}]},{"type":"text","text":"looks good, ship it"}]},"timestamp":"2026-03-28T10:15:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry (user text from mixed array), got %d: %+v", len(entries), entries)
	}
	if entries[0].Role != "human" {
		t.Errorf("expected role 'human', got %q", entries[0].Role)
	}
	if entries[0].Content != "looks good, ship it" {
		t.Errorf("expected 'looks good, ship it', got %q", entries[0].Content)
	}
}

func TestReadConversation_PureToolResultArraySkipped(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	// Pure tool_result array with no text blocks — should produce no entries.
	jsonl := `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"tool output"}]},{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":"more output"}]}]},"timestamp":"2026-03-28T10:15:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 0 {
		t.Fatalf("expected 0 entries for pure tool_result array, got %d: %+v", len(entries), entries)
	}
}

func TestReadConversation_MultipleTextBlocksJoined(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	// Array with multiple text blocks — should be joined with newline.
	jsonl := `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"first part"},{"type":"tool_result","tool_use_id":"t1","content":[]},{"type":"text","text":"second part"}]},"timestamp":"2026-03-28T10:15:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry, got %d: %+v", len(entries), entries)
	}
	if entries[0].Content != "first part\nsecond part" {
		t.Errorf("expected joined text, got %q", entries[0].Content)
	}
}

func TestHasPendingToolUse_NoPending(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Assistant sends tool_use, then user sends tool_result -> not pending
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"file.go"}]}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingToolUse(projDir, sessionID) {
		t.Error("expected no pending tool_use, but got true")
	}
}

func TestHasPendingToolUse_Pending(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Assistant sends tool_use with no subsequent tool_result -> pending
	jsonl := `{"type":"user","message":{"role":"user","content":"fix the bug"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix it."},{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"foo.go"}}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if !HasPendingToolUse(projDir, sessionID) {
		t.Error("expected pending tool_use, but got false")
	}
}

func TestHasPendingToolUse_EmptyFile(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(""), 0644)

	if HasPendingToolUse(projDir, sessionID) {
		t.Error("expected false for empty file")
	}
}

func TestHasPendingToolUse_MissingFile(t *testing.T) {
	if HasPendingToolUse("/nonexistent", "no-such") {
		t.Error("expected false for missing file")
	}
}

func TestHasPendingToolUse_TextOnlyAssistant(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Last assistant message has only text, no tool_use -> not pending
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"All done!"}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingToolUse(projDir, sessionID) {
		t.Error("expected no pending tool_use for text-only assistant message")
	}
}

func TestReadConversation_SkipsMalformedLines(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	jsonl := `not json at all
{"type":"user","message":{"role":"user","content":"valid"},"timestamp":"2026-03-28T10:15:00Z"}
{"broken json
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry, got %d", len(entries))
	}
}

func TestFindSubagents_SortedByStartTimeDescending(t *testing.T) {
	dir := t.TempDir()
	sessionID := "sess-1"
	subDir := filepath.Join(dir, sessionID, "subagents")
	os.MkdirAll(subDir, 0755)

	// Create 3 subagents with different start times
	agents := []struct {
		id        string
		agentType string
		desc      string
		timestamp string // first JSONL entry timestamp
	}{
		{"aaa", "Explore", "oldest agent", "2026-03-28T10:00:00Z"},
		{"bbb", "Bash", "middle agent", "2026-03-28T11:00:00Z"},
		{"ccc", "Plan", "newest agent", "2026-03-28T12:00:00Z"},
	}

	for _, a := range agents {
		meta := subagentMeta{AgentType: a.agentType, Description: a.desc}
		data, _ := json.Marshal(meta)
		os.WriteFile(filepath.Join(subDir, "agent-"+a.id+".meta.json"), data, 0644)

		// JSONL with sessionId and timestamp
		jsonl := `{"type":"system","sessionId":"` + sessionID + `","timestamp":"` + a.timestamp + `"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"working"}],"stop_reason":"end_turn"},"timestamp":"` + a.timestamp + `"}
`
		os.WriteFile(filepath.Join(subDir, "agent-"+a.id+".jsonl"), []byte(jsonl), 0644)
	}

	subs := FindSubagents(dir, sessionID)
	if len(subs) != 3 {
		t.Fatalf("expected 3 subagents, got %d", len(subs))
	}

	// Should be sorted newest first: ccc, bbb, aaa
	if subs[0].AgentID != "ccc" {
		t.Errorf("expected first subagent to be 'ccc' (newest), got %q", subs[0].AgentID)
	}
	if subs[1].AgentID != "bbb" {
		t.Errorf("expected second subagent to be 'bbb' (middle), got %q", subs[1].AgentID)
	}
	if subs[2].AgentID != "aaa" {
		t.Errorf("expected third subagent to be 'aaa' (oldest), got %q", subs[2].AgentID)
	}

	// Verify StartedAt is populated
	if subs[0].StartedAt == "" {
		t.Error("expected StartedAt to be populated")
	}
}

func TestMarkNotifications_TagsTaskNotificationPair(t *testing.T) {
	entries := []domain.ConversationEntry{
		{Role: "human", Content: "fix the bug"},
		{Role: "assistant", Content: "I fixed it. Here's the summary of changes."},
		{Role: "human", Content: "<task-notification>\n<task-id>abc123</task-id>\n<status>completed</status>\n</task-notification>"},
		{Role: "assistant", Content: "Background agent completed."},
	}
	markNotifications(entries)

	if entries[0].IsNotification {
		t.Error("regular user message should not be marked as notification")
	}
	if entries[1].IsNotification {
		t.Error("regular assistant message should not be marked as notification")
	}
	if !entries[2].IsNotification {
		t.Error("task-notification user message should be marked as notification")
	}
	if !entries[3].IsNotification {
		t.Error("assistant response after task-notification should be marked as notification")
	}
}

func TestMarkNotifications_MultipleConsecutive(t *testing.T) {
	entries := []domain.ConversationEntry{
		{Role: "human", Content: "do the thing"},
		{Role: "assistant", Content: "Done. All changes committed."},
		{Role: "human", Content: "<task-notification><task-id>a1</task-id></task-notification>"},
		{Role: "assistant", Content: "Agent A completed."},
		{Role: "human", Content: "<task-notification><task-id>b2</task-id></task-notification>"},
		{Role: "assistant", Content: "Agent B completed."},
	}
	markNotifications(entries)

	if entries[0].IsNotification || entries[1].IsNotification {
		t.Error("substantive entries should not be marked")
	}
	for i := 2; i <= 5; i++ {
		if !entries[i].IsNotification {
			t.Errorf("entry %d should be marked as notification", i)
		}
	}
}

func TestMarkNotifications_NotificationAtEnd(t *testing.T) {
	entries := []domain.ConversationEntry{
		{Role: "human", Content: "hello"},
		{Role: "assistant", Content: "Hi there!"},
		{Role: "human", Content: "<task-notification><task-id>x</task-id></task-notification>"},
	}
	markNotifications(entries)

	if !entries[2].IsNotification {
		t.Error("trailing task-notification should still be marked")
	}
	if entries[0].IsNotification || entries[1].IsNotification {
		t.Error("substantive entries should not be marked")
	}
}

func TestReadConversation_MarksNotifications(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	jsonl := `{"type":"user","message":{"role":"user","content":"fix the bug"},"timestamp":"2026-03-28T10:15:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Fixed!"}]},"timestamp":"2026-03-28T10:15:30Z"}
{"type":"user","message":{"role":"user","content":"<task-notification><task-id>bg1</task-id><status>completed</status></task-notification>"},"timestamp":"2026-03-28T10:16:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Background done."}]},"timestamp":"2026-03-28T10:16:30Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 4 {
		t.Fatalf("expected 4 entries, got %d", len(entries))
	}

	if entries[0].IsNotification || entries[1].IsNotification {
		t.Error("substantive entries should not be notifications")
	}
	if !entries[2].IsNotification {
		t.Error("task-notification user entry should be marked")
	}
	if !entries[3].IsNotification {
		t.Error("assistant response to task-notification should be marked")
	}
}

func TestIsSubagentCompleted_EndTurn(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "agent.jsonl")
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"stop_reason":"end_turn"},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(path, []byte(jsonl), 0644)
	if !isSubagentCompleted(path) {
		t.Error("expected completed for stop_reason=end_turn")
	}
}

func TestIsSubagentCompleted_ResultType(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "agent.jsonl")
	// Some subagents end with a "result" type entry
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"working"}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"result","result":"success","timestamp":"2026-03-28T10:01:00Z"}
`
	os.WriteFile(path, []byte(jsonl), 0644)
	if !isSubagentCompleted(path) {
		t.Error("expected completed for type=result entry")
	}
}

func TestIsSubagentCompleted_MaxTokens(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "agent.jsonl")
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ran out"}],"stop_reason":"max_tokens"},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(path, []byte(jsonl), 0644)
	if !isSubagentCompleted(path) {
		t.Error("expected completed for stop_reason=max_tokens")
	}
}

func TestIsSubagentCompleted_StillRunning(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "agent.jsonl")
	// Last entry is a tool_use with no stop_reason -- still running
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(path, []byte(jsonl), 0644)
	if isSubagentCompleted(path) {
		t.Error("expected not completed for active tool_use")
	}
}

func TestReadConversation_LargeAssistantMessageNotTruncated(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	if err := os.MkdirAll(projDir, 0755); err != nil {
		t.Fatalf("setup: %v", err)
	}

	// Create a 20000-char assistant message (simulating a plan)
	longText := strings.Repeat("x", 20000)

	msg := map[string]interface{}{
		"role": "assistant",
		"content": []map[string]string{
			{"type": "text", "text": longText},
		},
	}
	msgJSON, err := json.Marshal(msg)
	if err != nil {
		t.Fatalf("setup: %v", err)
	}
	entry := `{"type":"assistant","message":` + string(msgJSON) + `,"timestamp":"2026-03-28T10:15:30Z"}`

	if err := os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(entry+"\n"), 0644); err != nil {
		t.Fatalf("setup: %v", err)
	}

	entries := ReadConversation(filepath.Join(dir, slug), sessionID, 10)
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry, got %d", len(entries))
	}

	// The full 20000-char message should be preserved, not truncated to 8000
	if len(entries[0].Content) != 20000 {
		t.Errorf("expected content length 20000, got %d (message was truncated)", len(entries[0].Content))
	}
}

func TestReadPlanSlug_ExtractsSlug(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	jsonl := `{"type":"user","message":{"role":"user","content":"fix it"},"timestamp":"2026-03-28T10:00:00Z","slug":"my-cool-plan"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done"}]},"timestamp":"2026-03-28T10:00:01Z","slug":"my-cool-plan"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	slug := ReadPlanSlug(projDir, sessionID)
	if slug != "my-cool-plan" {
		t.Errorf("expected slug 'my-cool-plan', got %q", slug)
	}
}

func TestReadPlanSlug_NoSlug(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	jsonl := `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	slug := ReadPlanSlug(projDir, sessionID)
	if slug != "" {
		t.Errorf("expected empty slug, got %q", slug)
	}
}

func TestReadPlanSlug_MissingFile(t *testing.T) {
	slug := ReadPlanSlug("/nonexistent", "no-such")
	if slug != "" {
		t.Errorf("expected empty slug for missing file, got %q", slug)
	}
}

func TestReadPlanSlug_LargeFile(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Create a JSONL file larger than 32KB to trigger tail-seek logic
	var builder strings.Builder
	// Write many entries without slug to pad the file past 32KB
	for i := 0; i < 300; i++ {
		builder.WriteString(fmt.Sprintf(`{"type":"user","message":{"role":"user","content":"padding message %d with extra content to make it longer"},"timestamp":"2026-03-28T10:00:00Z"}`, i))
		builder.WriteByte('\n')
	}
	// Write the slug entries near the end
	builder.WriteString(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"plan time"}]},"timestamp":"2026-03-28T10:00:01Z","slug":"the-plan-slug"}`)
	builder.WriteByte('\n')

	data := builder.String()
	if len(data) < 32*1024 {
		t.Fatalf("test file too small: %d bytes, need >32KB", len(data))
	}
	t.Logf("JSONL file size: %d bytes", len(data))

	if err := os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(data), 0644); err != nil {
		t.Fatal(err)
	}

	slug := ReadPlanSlug(projDir, sessionID)
	if slug != "the-plan-slug" {
		t.Errorf("expected slug 'the-plan-slug' for large file, got %q", slug)
	}
}

func TestReadPlanSlug_LargeFileAllEntriesHaveSlug(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Create a JSONL file larger than 32KB where ALL entries have slug
	var builder strings.Builder
	for i := 0; i < 300; i++ {
		builder.WriteString(fmt.Sprintf(`{"type":"user","message":{"role":"user","content":"message %d with extra content for padding purposes"},"timestamp":"2026-03-28T10:00:00Z","slug":"my-session-slug"}`, i))
		builder.WriteByte('\n')
	}

	data := builder.String()
	if len(data) < 32*1024 {
		t.Fatalf("test file too small: %d bytes, need >32KB", len(data))
	}

	if err := os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(data), 0644); err != nil {
		t.Fatal(err)
	}

	slug := ReadPlanSlug(projDir, sessionID)
	if slug != "my-session-slug" {
		t.Errorf("expected 'my-session-slug', got %q", slug)
	}
}

func TestReadPlanSlug_LargeFileSparseSlug(t *testing.T) {
	// Regression test: large file (>32KB) with a single slug entry after
	// the tail seek point. Verifies that the partial-line skip doesn't
	// consume data needed by the scanner.
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	const tailSize = 32 * 1024
	var builder strings.Builder

	// Pad with no-slug entries to push past 32KB total
	padLine := `{"type":"user","message":{"role":"user","content":"` +
		strings.Repeat("x", 200) + `"},"timestamp":"2026-03-28T10:00:00Z"}` + "\n"
	for builder.Len() < tailSize+100 {
		builder.WriteString(padLine)
	}

	// Single slug entry in the tail window
	builder.WriteString(`{"type":"assistant","timestamp":"2026-03-28T10:00:01Z","slug":"sparse-slug"}` + "\n")

	for i := 0; i < 5; i++ {
		builder.WriteString(padLine)
	}

	data := builder.String()
	t.Logf("JSONL file size: %d bytes", len(data))

	if err := os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(data), 0644); err != nil {
		t.Fatal(err)
	}

	slug := ReadPlanSlug(projDir, sessionID)
	if slug != "sparse-slug" {
		t.Errorf("expected 'sparse-slug', got %q", slug)
	}
}

func TestReadPlanContent_ReadsFile(t *testing.T) {
	dir := t.TempDir()
	planContent := "# My Plan\n\n## Steps\n1. Do the thing\n2. Test the thing"
	os.MkdirAll(filepath.Join(dir, "plans"), 0755)
	os.WriteFile(filepath.Join(dir, "plans", "my-plan.md"), []byte(planContent), 0644)

	content := ReadPlanContent(filepath.Join(dir, "plans"), "my-plan")
	if content != planContent {
		t.Errorf("expected plan content, got %q", content)
	}
}

func TestReadPlanContent_MissingFile(t *testing.T) {
	content := ReadPlanContent("/nonexistent", "no-plan")
	if content != "" {
		t.Errorf("expected empty for missing file, got %q", content)
	}
}

func TestReadPlanContent_TruncatesLarge(t *testing.T) {
	dir := t.TempDir()
	os.MkdirAll(filepath.Join(dir, "plans"), 0755)
	large := strings.Repeat("x", 40000)
	os.WriteFile(filepath.Join(dir, "plans", "big.md"), []byte(large), 0644)

	content := ReadPlanContent(filepath.Join(dir, "plans"), "big")
	if len(content) > 32003 { // truncate adds "\u2026" (3 bytes UTF-8)
		t.Errorf("expected truncated content, got length %d", len(content))
	}
}

func TestReadDelegatedPlanContent_HappyPath(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	toolUseID := "toolu_01ABC"
	planText := "# Plan\n\n## Phase 1\nDo the thing."

	// One assistant tool_use (the Agent call) followed by a user message
	// containing the tool_result. Mirrors the real CC transcript shape.
	jsonl := `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01ABC","name":"Agent","input":{"subagent_type":"Plan"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":[{"type":"text","text":"# Plan\n\n## Phase 1\nDo the thing."}]}]}}
`
	if err := os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644); err != nil {
		t.Fatal(err)
	}

	got := ReadDelegatedPlanContent(projDir, sessionID, toolUseID)
	if got != planText {
		t.Errorf("expected plan text %q, got %q", planText, got)
	}
}

func TestReadDelegatedPlanContent_EmptyToolUseID(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":[{"type":"text","text":"plan"}]}]}}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, ""); got != "" {
		t.Errorf("expected empty when toolUseID is empty, got %q", got)
	}
}

func TestReadDelegatedPlanContent_MismatchedID(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_OTHER","content":[{"type":"text","text":"plan"}]}]}}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_01ABC"); got != "" {
		t.Errorf("expected empty when no tool_result matches, got %q", got)
	}
}

func TestReadDelegatedPlanContent_MissingFile(t *testing.T) {
	if got := ReadDelegatedPlanContent("/nonexistent", "no-such", "toolu_01"); got != "" {
		t.Errorf("expected empty for missing file, got %q", got)
	}
}

func TestReadDelegatedPlanContent_MultipleMatchesReturnsLast(t *testing.T) {
	// The orchestrator may delegate to Plan multiple times in a session. The
	// dashboard should always show the most recent plan.
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_X","content":[{"type":"text","text":"first plan"}]}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_X","content":[{"type":"text","text":"second plan"}]}]}}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_X"); got != "second plan" {
		t.Errorf("expected last match 'second plan', got %q", got)
	}
}

func TestReadDelegatedPlanContent_MalformedLineSkipped(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := "this is not json\n" +
		`{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_X","content":[{"type":"text","text":"good plan"}]}]}}` + "\n" +
		"another bad line\n"
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_X"); got != "good plan" {
		t.Errorf("expected 'good plan' (malformed lines skipped), got %q", got)
	}
}

func TestReadDelegatedPlanContent_RawStringContent(t *testing.T) {
	// Some tool_result payloads use a raw JSON string for content (e.g. Bash
	// results, older subagent responses). Verify both content shapes are
	// supported.
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_X","content":"raw plan text"}]}}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_X"); got != "raw plan text" {
		t.Errorf("expected 'raw plan text', got %q", got)
	}
}

func TestReadDelegatedPlanContent_MultipleTextBlocksJoined(t *testing.T) {
	// Plan subagent results can include a reasoning block followed by the
	// actual plan. Both should be returned, joined.
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"
	jsonl := `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_X","content":[{"type":"text","text":"reasoning"},{"type":"text","text":"plan body"}]}]}}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_X")
	if !strings.Contains(got, "reasoning") || !strings.Contains(got, "plan body") {
		t.Errorf("expected both blocks present, got %q", got)
	}
}

func TestReadDelegatedPlanContent_LargeFile(t *testing.T) {
	// Plan tool_results live near the end of the transcript \u2014 verify the
	// tail-scan window is large enough (>32KB) to find them.
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	var b strings.Builder
	padLine := `{"type":"user","message":{"content":"` + strings.Repeat("x", 200) + `"}}` + "\n"
	for b.Len() < 64*1024 { // push past 32KB
		b.WriteString(padLine)
	}
	b.WriteString(`{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_LATE","content":[{"type":"text","text":"the plan in the tail"}]}]}}` + "\n")
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(b.String()), 0644)

	if got := ReadDelegatedPlanContent(projDir, sessionID, "toolu_LATE"); got != "the plan in the tail" {
		t.Errorf("expected 'the plan in the tail', got %q", got)
	}
}

func TestIsSubagentCompleted_LargeFinalEntry(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "agent.jsonl")

	// The real bug: the final assistant message with stop_reason exceeds 4KB.
	// When isSubagentCompleted seeks to (fileSize - 4KB), it lands mid-entry.
	// The partial line fails JSON parsing and no complete line follows.
	largeText := strings.Repeat("x", 6000)

	// Small initial entry + large final entry with stop_reason
	jsonl := `{"type":"system","sessionId":"sess-1","timestamp":"2026-03-28T09:59:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"` + largeText + `"}],"stop_reason":"end_turn"},"timestamp":"2026-03-28T10:00:00Z"}
`
	if err := os.WriteFile(path, []byte(jsonl), 0644); err != nil {
		t.Fatalf("setup: %v", err)
	}

	// File is >4KB but the completion signal is in the final entry which spans beyond 4KB
	if len(jsonl) <= 4*1024 {
		t.Fatalf("test setup error: file too small (%d bytes), need >4KB", len(jsonl))
	}

	if !isSubagentCompleted(path) {
		t.Error("expected completed: large final entry with stop_reason=end_turn should be detected even when entry exceeds 4KB tail buffer")
	}
}

func TestCleanSlashCommand(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{
			name:  "slash command with args",
			input: "<command-message>skills:feature</command-message>\n<command-name>/skills:feature</command-name>\n<command-args>fix the login bug</command-args>",
			want:  "/skills:feature fix the login bug",
		},
		{
			name:  "slash command without args",
			input: "<command-message>skills:feature</command-message>\n<command-name>/skills:feature</command-name>\n<command-args></command-args>",
			want:  "/skills:feature",
		},
		{
			name:  "regular user message unchanged",
			input: "fix the bug",
			want:  "fix the bug",
		},
		{
			name:  "multiline args preserved",
			input: "<command-message>skills:plan</command-message>\n<command-name>/skills:plan</command-name>\n<command-args>refactor auth\nand add tests</command-args>",
			want:  "/skills:plan refactor auth\nand add tests",
		},
		{
			name:  "no command-name tag returns original",
			input: "<command-message>something</command-message>\nbut no command-name",
			want:  "<command-message>something</command-message>\nbut no command-name",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := cleanSlashCommand(tt.input)
			if got != tt.want {
				t.Errorf("cleanSlashCommand(%q)\n  got:  %q\n  want: %q", tt.input, got, tt.want)
			}
		})
	}
}

func TestStripSystemTags(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{
			name:  "system-reminder stripped",
			input: "hello <system-reminder>\nsome internal stuff\n</system-reminder> world",
			want:  "hello  world",
		},
		{
			name:  "task-notification NOT stripped (handled by IsNotification)",
			input: "<task-notification>\n<task-id>abc</task-id>\n</task-notification>\nActual message",
			want:  "<task-notification>\n<task-id>abc</task-id>\n</task-notification>\nActual message",
		},
		{
			name:  "command-message stripped",
			input: "<command-message>skill:foo</command-message>\nrest of text",
			want:  "rest of text",
		},
		{
			name:  "multiple tags stripped",
			input: "start <system-reminder>x</system-reminder> middle <command-message>y</command-message> end",
			want:  "start  middle  end",
		},
		{
			name:  "no tags unchanged",
			input: "plain user message",
			want:  "plain user message",
		},
		{
			name:  "system-reminder with attributes",
			input: "before <system-reminder foo=\"bar\">\ncontent\n</system-reminder> after",
			want:  "before  after",
		},
		{
			name:  "empty after stripping",
			input: "<system-reminder>only this</system-reminder>",
			want:  "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := stripSystemTags(tt.input)
			if got != tt.want {
				t.Errorf("stripSystemTags(%q)\n  got:  %q\n  want: %q", tt.input, got, tt.want)
			}
		})
	}
}

func TestConversationEqual(t *testing.T) {
	a := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z"},
		{Role: "assistant", Content: "hi", Timestamp: "2026-03-28T10:00:01Z"},
	}
	b := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z"},
		{Role: "assistant", Content: "hi", Timestamp: "2026-03-28T10:00:01Z"},
	}
	if !ConversationEqual(a, b) {
		t.Error("identical slices should be equal")
	}
}

func TestConversationEqual_DifferentLength(t *testing.T) {
	a := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z"},
	}
	b := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z"},
		{Role: "assistant", Content: "hi", Timestamp: "2026-03-28T10:00:01Z"},
	}
	if ConversationEqual(a, b) {
		t.Error("different lengths should not be equal")
	}
}

func TestConversationEqual_DifferentContent(t *testing.T) {
	a := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z"},
	}
	b := []domain.ConversationEntry{
		{Role: "human", Content: "goodbye", Timestamp: "2026-03-28T10:00:00Z"},
	}
	if ConversationEqual(a, b) {
		t.Error("different content should not be equal")
	}
}

func TestConversationEqual_DifferentNotification(t *testing.T) {
	a := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z", IsNotification: false},
	}
	b := []domain.ConversationEntry{
		{Role: "human", Content: "hello", Timestamp: "2026-03-28T10:00:00Z", IsNotification: true},
	}
	if ConversationEqual(a, b) {
		t.Error("different IsNotification should not be equal")
	}
}

func TestConversationEqual_BothNil(t *testing.T) {
	if !ConversationEqual(nil, nil) {
		t.Error("both nil should be equal")
	}
}

func TestConversationEqual_BothEmpty(t *testing.T) {
	if !ConversationEqual([]domain.ConversationEntry{}, []domain.ConversationEntry{}) {
		t.Error("both empty should be equal")
	}
}

func TestReadConversationIncremental_FirstRead(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"

	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)

	jsonl := `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	entries, offset := ReadConversationIncremental(filepath.Join(dir, slug), sessionID, 50, nil, 0)
	if len(entries) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(entries))
	}
	if offset == 0 {
		t.Error("expected non-zero offset after first read")
	}
	if entries[0].Content != "hello" || entries[1].Content != "hi" {
		t.Errorf("unexpected entries: %+v", entries)
	}
}

func TestReadConversationIncremental_AppendNew(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"
	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)
	path := filepath.Join(projDir, sessionID+".jsonl")

	// Initial write
	jsonl1 := `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(path, []byte(jsonl1), 0644)

	entries, offset := ReadConversationIncremental(projDir, sessionID, 50, nil, 0)
	if len(entries) != 2 {
		t.Fatalf("first read: expected 2 entries, got %d", len(entries))
	}

	// Append more entries
	f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
	f.WriteString(`{"type":"user","message":{"role":"user","content":"more"},"timestamp":"2026-03-28T10:01:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]},"timestamp":"2026-03-28T10:01:01Z"}
`)
	f.Close()

	// Incremental read should only parse new entries
	entries2, offset2 := ReadConversationIncremental(projDir, sessionID, 50, entries, offset)
	if len(entries2) != 4 {
		t.Fatalf("second read: expected 4 entries, got %d", len(entries2))
	}
	if offset2 <= offset {
		t.Error("offset should advance")
	}
	if entries2[2].Content != "more" || entries2[3].Content != "ok" {
		t.Errorf("unexpected new entries: %+v", entries2[2:])
	}
}

func TestReadConversationIncremental_RespectsLimit(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"
	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)
	path := filepath.Join(projDir, sessionID+".jsonl")

	// Write 6 entries, limit 4
	var builder strings.Builder
	for i := 0; i < 6; i++ {
		builder.WriteString(fmt.Sprintf(`{"type":"user","message":{"role":"user","content":"msg%d"},"timestamp":"2026-03-28T10:%02d:00Z"}`, i, i))
		builder.WriteByte('\n')
	}
	os.WriteFile(path, []byte(builder.String()), 0644)

	entries, _ := ReadConversationIncremental(projDir, sessionID, 4, nil, 0)
	if len(entries) != 4 {
		t.Fatalf("expected 4 entries (limit), got %d", len(entries))
	}
	// Should be the last 4
	if entries[0].Content != "msg2" {
		t.Errorf("expected msg2 first, got %s", entries[0].Content)
	}
}

func TestReadConversationIncremental_FileShrunk(t *testing.T) {
	dir := t.TempDir()
	slug := "test-project"
	sessionID := "abc-123"
	projDir := filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)
	path := filepath.Join(projDir, sessionID+".jsonl")

	// Write data
	jsonl := `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(path, []byte(jsonl), 0644)
	_, offset := ReadConversationIncremental(projDir, sessionID, 50, nil, 0)

	// Truncate and rewrite smaller content
	os.WriteFile(path, []byte(`{"type":"user","message":{"role":"user","content":"new"},"timestamp":"2026-03-28T11:00:00Z"}
`), 0644)

	// Should detect shrink and do full re-read
	entries, _ := ReadConversationIncremental(projDir, sessionID, 50, nil, offset)
	if len(entries) != 1 {
		t.Fatalf("expected 1 entry after shrink, got %d", len(entries))
	}
	if entries[0].Content != "new" {
		t.Errorf("expected 'new', got %s", entries[0].Content)
	}
}

func TestReadConversationIncremental_MissingFile(t *testing.T) {
	entries, offset := ReadConversationIncremental("/nonexistent", "no-such", 50, nil, 0)
	if len(entries) != 0 {
		t.Errorf("expected empty, got %d entries", len(entries))
	}
	if offset != 0 {
		t.Errorf("expected 0 offset, got %d", offset)
	}
}

func TestParseUserEntry_SlashCommand(t *testing.T) {
	content := "<command-message>skills:refactor</command-message>\n<command-name>/skills:refactor</command-name>\n<command-args>clean up the auth module</command-args>"
	contentJSON, _ := json.Marshal(content)
	msgJSON, _ := json.Marshal(map[string]json.RawMessage{
		"role":    json.RawMessage(`"user"`),
		"content": json.RawMessage(contentJSON),
	})
	entry := jsonlEntry{
		Type:      "user",
		Message:   json.RawMessage(msgJSON),
		Timestamp: "2026-03-28T10:00:00Z",
	}

	result := parseUserEntry(entry)
	if result == nil {
		t.Fatal("expected non-nil result")
	}
	want := "/skills:refactor clean up the auth module"
	if result.Content != want {
		t.Errorf("got %q, want %q", result.Content, want)
	}
}

// -- HasPendingPlanReview tests --

func TestHasPendingPlanReview_Pending(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// Last assistant turn has ExitPlanMode tool_use, no user response -> pending
	jsonl := `{"type":"user","message":{"role":"user","content":"plan it"},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Here is the plan."},{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if !HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected pending plan review, but got false")
	}
}

func TestHasPendingPlanReview_Approved(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// ExitPlanMode followed by user message -> approved, not pending
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":"yes, go ahead"},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected no pending plan review after user response")
	}
}

func TestHasPendingPlanReview_NoPlan(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// No ExitPlanMode at all
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"All done!"}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected false for no ExitPlanMode")
	}
}

func TestHasPendingPlanReview_PendingWithToolResult(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// ExitPlanMode followed by tool_result only -> still pending (tool_result is system-generated, not human input)
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"Plan submitted for review"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if !HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected pending plan review -- tool_result is not a human response")
	}
}

func TestHasPendingPlanReview_ApprovedAfterToolResult(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// ExitPlanMode -> tool_result -> human text -> approved
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"Plan submitted"}]},"timestamp":"2026-03-28T10:00:01Z"}
{"type":"user","message":{"role":"user","content":"yes, go ahead"},"timestamp":"2026-03-28T10:00:02Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected no pending plan review -- human approved after tool_result")
	}
}

func TestHasPendingPlanReview_PendingWithAssistantAfterToolResult(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// ExitPlanMode -> tool_result -> assistant text (continuation, no human input) -> still pending
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"Plan submitted"}]},"timestamp":"2026-03-28T10:00:01Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I have presented my plan for review."}]},"timestamp":"2026-03-28T10:00:02Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if !HasPendingPlanReview(projDir, sessionID) {
		t.Error("expected pending plan review -- assistant text after tool_result is not human approval")
	}
}

func TestHasPendingPlanReview_MissingFile(t *testing.T) {
	if HasPendingPlanReview("/nonexistent", "no-such") {
		t.Error("expected false for missing file")
	}
}

// -- HasPendingQuestion tests --

func TestHasPendingQuestion_Pending(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// AskUserQuestion followed by tool_result only -> still pending
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"AskUserQuestion","input":{"question":"Which approach?"}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"Option A"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if !HasPendingQuestion(projDir, sessionID) {
		t.Error("expected pending question -- tool_result is not a human response")
	}
}

func TestHasPendingQuestion_Answered(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// AskUserQuestion -> tool_result -> human text -> answered
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"AskUserQuestion","input":{"question":"Which?"}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"A"}]},"timestamp":"2026-03-28T10:00:01Z"}
{"type":"user","message":{"role":"user","content":"go with A"},"timestamp":"2026-03-28T10:00:02Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingQuestion(projDir, sessionID) {
		t.Error("expected no pending question -- human answered")
	}
}

func TestHasPendingQuestion_NoQuestion(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"All done!"}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingQuestion(projDir, sessionID) {
		t.Error("expected false for no AskUserQuestion")
	}
}

func TestHasPendingQuestion_PlanDoesNotTrigger(t *testing.T) {
	dir := t.TempDir()
	projDir := filepath.Join(dir, "proj")
	os.MkdirAll(projDir, 0755)
	sessionID := "sess-1"

	// ExitPlanMode should NOT trigger question detection
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:00Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"Plan submitted"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)

	if HasPendingQuestion(projDir, sessionID) {
		t.Error("expected false -- ExitPlanMode is not a question")
	}
}

// writeSessionFile creates a single session JSON in dir for the Locate tests.
func writeSessionFile(t *testing.T, dir, sessionID, cwd string, startedAt int64) {
	t.Helper()
	data := fmt.Sprintf(`{"pid":%d,"sessionId":%q,"cwd":%q,"startedAt":%d}`,
		1000+startedAt, sessionID, cwd, startedAt)
	if err := os.WriteFile(filepath.Join(dir, sessionID+".json"), []byte(data), 0644); err != nil {
		t.Fatalf("write session file: %v", err)
	}
}

func TestLocate_SingleCandidateExactMatch(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir, "/repo/feat")
	if got != "sess-1" {
		t.Errorf("Locate = %q, want sess-1", got)
	}
}

func TestLocate_NoMatch(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir, "/some/other")
	if got != "" {
		t.Errorf("Locate = %q, want empty", got)
	}
}

func TestLocate_MultiCandidateOneMatches(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir, "/wt/sub", "/repo/feat", "/repo")
	if got != "sess-1" {
		t.Errorf("Locate = %q, want sess-1 (matched on second candidate)", got)
	}
}

func TestLocate_TrailingSlashCleaned(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir, "/repo/feat/")
	if got != "sess-1" {
		t.Errorf("Locate = %q, want sess-1 (trailing slash should normalize via filepath.Clean)", got)
	}
}

func TestLocate_MostRecentWinsAcrossCandidates(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-old", "/repo", 100)
	writeSessionFile(t, dir, "sess-new", "/repo", 200)

	got := Locate(dir, "/repo")
	if got != "sess-new" {
		t.Errorf("Locate = %q, want sess-new (most recent)", got)
	}
}

func TestLocate_EmptyCandidatesFiltered(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir, "", "/repo/feat", "")
	if got != "sess-1" {
		t.Errorf("Locate = %q, want sess-1 (empties should be skipped)", got)
	}
}

func TestLocate_NoCandidates(t *testing.T) {
	dir := t.TempDir()
	writeSessionFile(t, dir, "sess-1", "/repo/feat", 100)

	got := Locate(dir)
	if got != "" {
		t.Errorf("Locate = %q, want empty (no candidates means no match)", got)
	}
}

func TestLocate_MissingDir(t *testing.T) {
	got := Locate("/nonexistent-sessions-dir-xyz", "/repo/feat")
	if got != "" {
		t.Errorf("Locate = %q, want empty for missing dir", got)
	}
}

// writeJSONLAt creates an empty <sessionID>.jsonl under projectsDir/<slug>/
// for PickProjDir tests. Returns the absolute directory path.
func writeJSONLAt(t *testing.T, projectsDir, cwd, sessionID string) string {
	t.Helper()
	slug := ProjectSlug(cwd)
	dir := filepath.Join(projectsDir, slug)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	path := filepath.Join(dir, sessionID+".jsonl")
	if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
		t.Fatalf("write jsonl: %v", err)
	}
	return dir
}

func TestPickProjDir_AgentCwdMatches(t *testing.T) {
	projectsDir := t.TempDir()
	want := writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := PickProjDir(projectsDir, "sess-1", "/repo", "/wt/feat", "/repo")
	if got != want {
		t.Errorf("PickProjDir = %q, want %q (first candidate hit)", got, want)
	}
}

func TestPickProjDir_FallsThroughToLaterCandidate(t *testing.T) {
	projectsDir := t.TempDir()
	want := writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	// Pane 4.1 shape: agent.Cwd is the worktree path (no JSONL there);
	// only the source repo slug has the JSONL.
	got := PickProjDir(projectsDir, "sess-1", "/wt/feat", "/wt/feat", "/wt/feat", "/repo")
	if got != want {
		t.Errorf("PickProjDir = %q, want %q (fell through to source candidate)", got, want)
	}
}

func TestPickProjDir_NoCandidateMatches_ReturnsEmpty(t *testing.T) {
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := PickProjDir(projectsDir, "sess-1", "/elsewhere", "/wt/feat")
	if got != "" {
		t.Errorf("PickProjDir = %q, want empty (no candidate matches)", got)
	}
}

func TestPickProjDir_DedupesEqualSlugs(t *testing.T) {
	projectsDir := t.TempDir()
	want := writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	// Multiple candidates resolving to the same slug — first hit wins,
	// duplicate slugs after that aren't restatted (regression: a non-linked
	// repo where Worktree == Source would produce two stat calls).
	got := PickProjDir(projectsDir, "sess-1", "/repo", "/repo/", "/repo")
	if got != want {
		t.Errorf("PickProjDir = %q, want %q", got, want)
	}
}

func TestPickProjDir_EmptyCandidatesSkipped(t *testing.T) {
	projectsDir := t.TempDir()
	want := writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := PickProjDir(projectsDir, "sess-1", "", "/repo", "")
	if got != want {
		t.Errorf("PickProjDir = %q, want %q (empties should be skipped)", got, want)
	}
}

func TestPickProjDir_NoCandidates(t *testing.T) {
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := PickProjDir(projectsDir, "sess-1")
	if got != "" {
		t.Errorf("PickProjDir = %q, want empty (no candidates)", got)
	}
}

func TestPickProjDir_EmptySessionID(t *testing.T) {
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	// Empty SessionID can't match any <sid>.jsonl file.
	got := PickProjDir(projectsDir, "", "/repo")
	if got != "" {
		t.Errorf("PickProjDir = %q, want empty (no SessionID)", got)
	}
}

// writeJSONLLines creates projectsDir/<slug>/<sessionID>.jsonl containing
// one JSON line per element in lines. Used by LastGitBranch tests.
func writeJSONLLines(t *testing.T, projectsDir, cwd, sessionID string, lines []string) string {
	t.Helper()
	slug := ProjectSlug(cwd)
	dir := filepath.Join(projectsDir, slug)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	path := filepath.Join(dir, sessionID+".jsonl")
	body := strings.Join(lines, "\n") + "\n"
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatalf("write jsonl: %v", err)
	}
	return dir
}

func TestLastGitBranch_ReturnsMostRecent(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		`{"type":"user","gitBranch":"main"}`,
		`{"type":"assistant","gitBranch":"main"}`,
		`{"type":"user","gitBranch":"feat/x"}`,
		`{"type":"assistant","gitBranch":"feat/x"}`,
	})

	got := LastGitBranch(dir, "sess-1")
	if got != "feat/x" {
		t.Errorf("LastGitBranch = %q, want feat/x (most recent)", got)
	}
}

func TestLastGitBranch_MissingFile_ReturnsEmpty(t *testing.T) {
	got := LastGitBranch("/nonexistent-projdir-xyz", "sess-1")
	if got != "" {
		t.Errorf("LastGitBranch = %q, want empty for missing file", got)
	}
}

func TestLastGitBranch_NoGitBranchField(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		`{"type":"user"}`,
		`{"type":"assistant"}`,
	})

	got := LastGitBranch(dir, "sess-1")
	if got != "" {
		t.Errorf("LastGitBranch = %q, want empty when no gitBranch fields", got)
	}
}

func TestLastGitBranch_SkipsEmptyValues(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		`{"type":"user","gitBranch":"feat/x"}`,
		`{"type":"assistant","gitBranch":""}`,
		`{"type":"user","gitBranch":""}`,
	})

	got := LastGitBranch(dir, "sess-1")
	if got != "feat/x" {
		t.Errorf("LastGitBranch = %q, want feat/x (empty values should be skipped, last non-empty wins)", got)
	}
}

func TestLastGitBranch_EmptySessionID(t *testing.T) {
	got := LastGitBranch(t.TempDir(), "")
	if got != "" {
		t.Errorf("LastGitBranch = %q, want empty for missing sessionID", got)
	}
}

func TestLastGitBranch_MalformedLineSkipped(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		`{"type":"user","gitBranch":"feat/x"}`,
		`not-json`,
		`{"type":"assistant","gitBranch":"feat/x"}`,
	})

	got := LastGitBranch(dir, "sess-1")
	if got != "feat/x" {
		t.Errorf("LastGitBranch = %q, want feat/x (malformed line should be skipped)", got)
	}
}

func TestFindProjDirByScan_HappyPath(t *testing.T) {
	projectsDir := t.TempDir()
	want := writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := FindProjDirByScan(projectsDir, "sess-1")
	if got != want {
		t.Errorf("FindProjDirByScan = %q, want %q", got, want)
	}
}

func TestFindProjDirByScan_FindsDeepInDir(t *testing.T) {
	// JSONL stored under one of many sibling slugs; scan finds it.
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo-A", "sess-other")
	writeJSONLAt(t, projectsDir, "/repo-B", "sess-other-2")
	want := writeJSONLAt(t, projectsDir, "/repo-target", "sess-1")

	got := FindProjDirByScan(projectsDir, "sess-1")
	if got != want {
		t.Errorf("FindProjDirByScan = %q, want %q", got, want)
	}
}

func TestFindProjDirByScan_NoMatch(t *testing.T) {
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo", "sess-other")

	got := FindProjDirByScan(projectsDir, "sess-1")
	if got != "" {
		t.Errorf("FindProjDirByScan = %q, want empty", got)
	}
}

func TestFindProjDirByScan_EmptySessionID(t *testing.T) {
	projectsDir := t.TempDir()
	writeJSONLAt(t, projectsDir, "/repo", "sess-1")

	got := FindProjDirByScan(projectsDir, "")
	if got != "" {
		t.Errorf("FindProjDirByScan = %q, want empty for missing sessionID", got)
	}
}

func TestFindProjDirByScan_MissingDir(t *testing.T) {
	got := FindProjDirByScan("/nonexistent-dir-xyz", "sess-1")
	if got != "" {
		t.Errorf("FindProjDirByScan = %q, want empty for missing dir", got)
	}
}

// bashToolUseLine returns a JSONL line representing an assistant message
// containing a single Bash tool_use block with the given command.
func bashToolUseLine(t *testing.T, command string) string {
	t.Helper()
	type input struct {
		Command string `json:"command"`
	}
	type block struct {
		Type  string `json:"type"`
		Name  string `json:"name"`
		Input input  `json:"input"`
	}
	type msg struct {
		Role    string  `json:"role"`
		Content []block `json:"content"`
	}
	type entry struct {
		Type    string `json:"type"`
		Message msg    `json:"message"`
	}
	e := entry{
		Type: "assistant",
		Message: msg{
			Role: "assistant",
			Content: []block{{
				Type:  "tool_use",
				Name:  "Bash",
				Input: input{Command: command},
			}},
		},
	}
	b, err := json.Marshal(e)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	return string(b)
}

func TestLastGitWorktreeAdd_AbsolutePath(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add /abs/path/to/wt"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "/abs/path/to/wt" {
		t.Errorf("LastGitWorktreeAdd = %q, want /abs/path/to/wt", got)
	}
}

func TestLastGitWorktreeAdd_RelativePath(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add ../worktrees/app/feat"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "../worktrees/app/feat" {
		t.Errorf("LastGitWorktreeAdd = %q, want ../worktrees/app/feat", got)
	}
}

func TestLastGitWorktreeAdd_FlagBranchPair(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add -b feat/x worktrees/foo"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "worktrees/foo" {
		t.Errorf("LastGitWorktreeAdd = %q, want worktrees/foo", got)
	}
}

func TestLastGitWorktreeAdd_BooleanFlag(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add --force worktrees/foo"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "worktrees/foo" {
		t.Errorf("LastGitWorktreeAdd = %q, want worktrees/foo", got)
	}
}

func TestLastGitWorktreeAdd_PathBeforeCommitish(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add -b feat/x worktrees/foo master"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "worktrees/foo" {
		t.Errorf("LastGitWorktreeAdd = %q, want worktrees/foo (path before commit-ish)", got)
	}
}

func TestLastGitWorktreeAdd_MostRecentWins(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git worktree add /first/path"),
		bashToolUseLine(t, "git worktree add /second/path"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "/second/path" {
		t.Errorf("LastGitWorktreeAdd = %q, want /second/path (most recent wins)", got)
	}
}

func TestLastGitWorktreeAdd_IgnoresNonMatching(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		bashToolUseLine(t, "git status"),
		bashToolUseLine(t, "cd /worktrees/foo"),
		bashToolUseLine(t, "git worktree list"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "" {
		t.Errorf("LastGitWorktreeAdd = %q, want empty (no matching command)", got)
	}
}

func TestLastGitWorktreeAdd_MalformedLineSkipped(t *testing.T) {
	projectsDir := t.TempDir()
	dir := writeJSONLLines(t, projectsDir, "/repo", "sess-1", []string{
		`not-json`,
		bashToolUseLine(t, "git worktree add /abs/path"),
	})

	got := LastGitWorktreeAdd(dir, "sess-1")
	if got != "/abs/path" {
		t.Errorf("LastGitWorktreeAdd = %q, want /abs/path (malformed line skipped)", got)
	}
}

func TestLastGitWorktreeAdd_EmptySessionID(t *testing.T) {
	got := LastGitWorktreeAdd(t.TempDir(), "")
	if got != "" {
		t.Errorf("LastGitWorktreeAdd = %q, want empty for missing sessionID", got)
	}
}

func TestLastGitWorktreeAdd_MissingFile(t *testing.T) {
	got := LastGitWorktreeAdd("/nonexistent-projdir-xyz", "sess-1")
	if got != "" {
		t.Errorf("LastGitWorktreeAdd = %q, want empty for missing file", got)
	}
}
