package state

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

	"github.com/bjornjee/agent-dashboard/internal/conversation"
	"github.com/bjornjee/agent-dashboard/internal/domain"
	"github.com/bjornjee/agent-dashboard/internal/mocks"
	"github.com/stretchr/testify/mock"
)

// writeAgentFile is a test helper that writes an agent JSON file to dir/agents/.
func writeAgentFile(t *testing.T, dir, filename string, agent domain.Agent) {
	t.Helper()
	agentsDir := filepath.Join(dir, "agents")
	if err := os.MkdirAll(agentsDir, 0755); err != nil {
		t.Fatalf("create agents dir: %v", err)
	}
	data, err := json.Marshal(agent)
	if err != nil {
		t.Fatalf("marshal agent: %v", err)
	}
	if err := os.WriteFile(filepath.Join(agentsDir, filename), data, 0644); err != nil {
		t.Fatalf("write agent file: %v", err)
	}
}

func TestReadState_MissingDir(t *testing.T) {
	sf := ReadState("/nonexistent/path")
	if len(sf.Agents) != 0 {
		t.Errorf("expected empty agents, got %d", len(sf.Agents))
	}
}

func TestReadState_EmptyAgentsDir(t *testing.T) {
	tmp := t.TempDir()
	os.MkdirAll(filepath.Join(tmp, "agents"), 0755)

	sf := ReadState(tmp)
	if len(sf.Agents) != 0 {
		t.Errorf("expected empty agents, got %d", len(sf.Agents))
	}
}

func TestReadState_InvalidJSON(t *testing.T) {
	tmp := t.TempDir()
	agentsDir := filepath.Join(tmp, "agents")
	os.MkdirAll(agentsDir, 0755)
	os.WriteFile(filepath.Join(agentsDir, "bad.json"), []byte("not json{{{"), 0644)

	sf := ReadState(tmp)
	if len(sf.Agents) != 0 {
		t.Errorf("expected empty agents for invalid JSON, got %d", len(sf.Agents))
	}
}

func TestReadState_ValidState(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "a:0.1", State: "running", Session: "a", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "b:1.0", State: "question", Session: "b", TmuxPaneID: "%2"})

	sf := ReadState(tmp)
	if len(sf.Agents) != 2 {
		t.Fatalf("expected 2 agents, got %d", len(sf.Agents))
	}
	if sf.Agents["sess-a"].State != "running" {
		t.Errorf("expected running, got %s", sf.Agents["sess-a"].State)
	}
}

func TestReadState_FallbackToFilename(t *testing.T) {
	tmp := t.TempDir()
	// domain.Agent without session_id — should use filename stem as key
	writeAgentFile(t, tmp, "fallback-key.json", domain.Agent{Target: "a:0.1", State: "running", Session: "a"})

	sf := ReadState(tmp)
	if len(sf.Agents) != 1 {
		t.Fatalf("expected 1 agent, got %d", len(sf.Agents))
	}
	if _, ok := sf.Agents["fallback-key"]; !ok {
		t.Error("expected agent keyed by filename stem 'fallback-key'")
	}
}

func TestSortedAgents_Priority(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s5": {Target: "a:5.0", State: "done", Window: 5, Pane: 0, TmuxPaneID: "%5"},
			"s1": {Target: "a:1.0", State: "question", Window: 1, Pane: 0, TmuxPaneID: "%1"},
			"s3": {Target: "a:3.0", State: "running", Window: 3, Pane: 0, TmuxPaneID: "%3"},
			"s0": {Target: "a:0.0", State: "permission", Window: 0, Pane: 0, TmuxPaneID: "%0"},
			"s4": {Target: "a:4.0", State: "idle_prompt", Window: 4, Pane: 0, TmuxPaneID: "%4"},
			"s2": {Target: "a:2.0", State: "error", Window: 2, Pane: 0, TmuxPaneID: "%2"},
		},
	}

	sorted := SortedAgents(sf, "")

	// Group 1: blocked (permission) sorted by window
	// Group 2: waiting (question, error) sorted by window
	// Group 3: running
	// Group 4: review (idle_prompt, done) sorted by window
	expected := []string{"permission", "question", "error", "running", "idle_prompt", "done"}

	if len(sorted) != 6 {
		t.Fatalf("expected 6 agents, got %d", len(sorted))
	}
	for i, want := range expected {
		if sorted[i].State != want {
			t.Errorf("position %d: expected %s, got %s", i, want, sorted[i].State)
		}
	}
}

func TestSortedAgents_ExcludesSelfByPaneID(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s1": {Target: "a:0.0", State: "running", TmuxPaneID: "%5"},
			"s2": {Target: "a:1.0", State: "running", TmuxPaneID: "%6"},
		},
	}

	sorted := SortedAgents(sf, "%5")
	if len(sorted) != 1 {
		t.Fatalf("expected 1 agent (self excluded), got %d", len(sorted))
	}
	if sorted[0].TmuxPaneID != "%6" {
		t.Errorf("expected pane %%6 to survive, got %s", sorted[0].TmuxPaneID)
	}
}

func TestSortedAgents_SkipsEmptyState(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"good":  {Target: "good", State: "running", TmuxPaneID: "%1"},
			"good2": {Target: "", State: "running"}, // empty target is ok
			"bad":   {Target: "bad", State: ""},     // empty state -> skipped
		},
	}

	sorted := SortedAgents(sf, "")
	if len(sorted) != 2 {
		t.Errorf("expected 2 valid agents (only empty-state skipped), got %d", len(sorted))
	}
}

func TestRemoveAgent(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "a:0.1", State: "running", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "b:1.0", State: "question", TmuxPaneID: "%2"})

	err := RemoveAgent(tmp, "sess-a")
	if err != nil {
		t.Fatalf("RemoveAgent failed: %v", err)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 1 {
		t.Fatalf("expected 1 agent after removal, got %d", len(sf.Agents))
	}
	if _, ok := sf.Agents["sess-a"]; ok {
		t.Error("agent sess-a should have been removed")
	}
	if _, ok := sf.Agents["sess-b"]; !ok {
		t.Error("agent sess-b should still exist")
	}
}

func TestRemoveAgent_NonExistent(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "a:0.1", State: "running"})

	err := RemoveAgent(tmp, "nonexistent")
	if err != nil {
		t.Fatalf("RemoveAgent should not fail on nonexistent session_id: %v", err)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 1 {
		t.Errorf("expected 1 agent unchanged, got %d", len(sf.Agents))
	}
}

func TestPruneDead_ByPaneID(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "main:1.0", State: "running", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "main:1.1", State: "done", TmuxPaneID: "%2"})

	livePaneIDs := map[string]bool{
		"%0": true, // dashboard
		"%1": true, // agent A alive
		// %2 is dead
	}

	removed := PruneDead(tmp, livePaneIDs)
	if removed != 1 {
		t.Errorf("expected 1 removed, got %d", removed)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 1 {
		t.Fatalf("expected 1 agent, got %d", len(sf.Agents))
	}
	if _, ok := sf.Agents["sess-a"]; !ok {
		t.Error("sess-a should survive")
	}
}

func TestPruneDead_EmptyLivePanes(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "main:1.0", State: "running", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "main:2.0", State: "running", TmuxPaneID: "%2"})

	// Empty non-nil map simulates tmux returning success with empty output.
	// PruneDead should refuse to delete all agents in this case.
	livePaneIDs := map[string]bool{}
	removed := PruneDead(tmp, livePaneIDs)
	if removed != 0 {
		t.Errorf("expected 0 removed with empty livePaneIDs, got %d", removed)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 2 {
		t.Fatalf("expected 2 agents preserved, got %d", len(sf.Agents))
	}
}

func TestPruneDead_AllAgentsDead(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "main:1.0", State: "running", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "main:2.0", State: "running", TmuxPaneID: "%2"})

	// livePaneIDs has panes but none match any agent — all would be deleted.
	// Safety net should refuse to wipe all agents at once.
	livePaneIDs := map[string]bool{
		"%0": true, // dashboard pane only
	}
	removed := PruneDead(tmp, livePaneIDs)
	if removed != 0 {
		t.Errorf("expected 0 removed when all agents would be wiped, got %d", removed)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 2 {
		t.Fatalf("expected 2 agents preserved, got %d", len(sf.Agents))
	}
}

func TestPruneDead_PartialDead(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "main:1.0", State: "running", TmuxPaneID: "%1"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "main:1.1", State: "running", TmuxPaneID: "%2"})
	writeAgentFile(t, tmp, "sess-c.json", domain.Agent{SessionID: "sess-c", Target: "main:2.0", State: "done", TmuxPaneID: "%3"})

	livePaneIDs := map[string]bool{
		"%0": true,
		"%1": true,
		"%2": true,
		// %3 is dead
	}

	removed := PruneDead(tmp, livePaneIDs)
	if removed != 1 {
		t.Errorf("expected 1 removed, got %d", removed)
	}

	sf := ReadState(tmp)
	if len(sf.Agents) != 2 {
		t.Fatalf("expected 2 agents after partial prune, got %d", len(sf.Agents))
	}
	if _, ok := sf.Agents["sess-c"]; ok {
		t.Error("sess-c should have been pruned")
	}
}

func TestPruneDead_NoPaneID(t *testing.T) {
	tmp := t.TempDir()
	// domain.Agent without TmuxPaneID — should be considered dead
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "main:1.0", State: "running"})
	writeAgentFile(t, tmp, "sess-b.json", domain.Agent{SessionID: "sess-b", Target: "main:2.0", State: "running", TmuxPaneID: "%2"})

	livePaneIDs := map[string]bool{
		"%2": true,
	}

	removed := PruneDead(tmp, livePaneIDs)
	if removed != 1 {
		t.Errorf("expected 1 removed (agent without pane ID), got %d", removed)
	}
}

func TestPruneDead_DedupSamePane(t *testing.T) {
	tmp := t.TempDir()
	recentTime := time.Now().UTC().Format(time.RFC3339)
	staleTime := "2020-01-01T00:00:00Z"

	// Two agents sharing pane %42 — both panes alive.
	// PruneDead should keep only the newest and remove the older duplicate.
	writeAgentFile(t, tmp, "old-session.json", domain.Agent{
		SessionID:  "old-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  staleTime,
	})
	writeAgentFile(t, tmp, "new-session.json", domain.Agent{
		SessionID:  "new-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  recentTime,
	})

	livePaneIDs := map[string]bool{"%42": true}
	removed := PruneDead(tmp, livePaneIDs)
	if removed != 1 {
		t.Errorf("expected 1 removed (older duplicate pane agent), got %d", removed)
	}

	agentsPath := filepath.Join(tmp, "agents")
	if _, err := os.Stat(filepath.Join(agentsPath, "old-session.json")); err == nil {
		t.Error("old duplicate-pane agent was not cleaned by PruneDead")
	}
	if _, err := os.Stat(filepath.Join(agentsPath, "new-session.json")); err != nil {
		t.Error("newest agent for live pane was incorrectly removed")
	}
}

func TestPruneDead_DedupOnDeadPane(t *testing.T) {
	tmp := t.TempDir()
	recentTime := time.Now().UTC().Format(time.RFC3339)
	staleTime := "2020-01-01T00:00:00Z"

	// Two agents sharing pane %42 which is dead (not in livePaneIDs).
	// The dedup removal of the older agent should still happen even though
	// the safety net prevents removing all dead agents at once.
	writeAgentFile(t, tmp, "old-session.json", domain.Agent{
		SessionID:  "old-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  staleTime,
	})
	writeAgentFile(t, tmp, "new-session.json", domain.Agent{
		SessionID:  "new-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  recentTime,
	})

	livePaneIDs := map[string]bool{} // pane %42 is dead
	removed := PruneDead(tmp, livePaneIDs)
	if removed != 1 {
		t.Errorf("expected 1 removed (dedup of older agent on dead pane), got %d", removed)
	}

	agentsPath := filepath.Join(tmp, "agents")
	// Old duplicate should always be cleaned (dedup is unconditional)
	if _, err := os.Stat(filepath.Join(agentsPath, "old-session.json")); err == nil {
		t.Error("old duplicate-pane agent was not cleaned by PruneDead dedup")
	}
	// Newest agent is kept by the safety net (sole remaining dead agent)
	if _, err := os.Stat(filepath.Join(agentsPath, "new-session.json")); err != nil {
		t.Error("newest agent was incorrectly removed")
	}
}

func TestResolveAgentTargets(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s1": {
				SessionID:  "s1",
				Target:     "tomoro:3.2",
				TmuxPaneID: "%90",
				Session:    "tomoro",
				Window:     3,
				Pane:       2,
				State:      "running",
			},
			"s2": {
				SessionID:  "s2",
				Target:     "tomoro:2.1",
				TmuxPaneID: "%87",
				Session:    "tomoro",
				Window:     2,
				Pane:       1,
				State:      "input",
			},
			"s3": {
				// No TmuxPaneID — should be left unchanged
				SessionID: "s3",
				Target:    "tomoro:1.0",
				Session:   "tomoro",
				Window:    1,
				Pane:      0,
				State:     "done",
			},
		},
	}

	// Simulate tmux having renumbered windows (window 3 -> 2, window 2 -> 1)
	paneTargets := map[string]domain.PaneTarget{
		"%90": {Session: "tomoro", Window: 2, Pane: 2, Target: "tomoro:2.2"},
		"%87": {Session: "tomoro", Window: 1, Pane: 1, Target: "tomoro:1.1"},
	}

	ResolveAgentTargets(&sf, paneTargets)

	// s1 should be updated
	s1 := sf.Agents["s1"]
	if s1.Target != "tomoro:2.2" {
		t.Errorf("s1.Target = %q, want %q", s1.Target, "tomoro:2.2")
	}
	if s1.Window != 2 || s1.Pane != 2 {
		t.Errorf("s1 Window.Pane = %d.%d, want 2.2", s1.Window, s1.Pane)
	}
	if s1.Session != "tomoro" {
		t.Errorf("s1.Session = %q, want %q", s1.Session, "tomoro")
	}

	// s2 should be updated
	s2 := sf.Agents["s2"]
	if s2.Target != "tomoro:1.1" {
		t.Errorf("s2.Target = %q, want %q", s2.Target, "tomoro:1.1")
	}
	if s2.Window != 1 || s2.Pane != 1 {
		t.Errorf("s2 Window.Pane = %d.%d, want 1.1", s2.Window, s2.Pane)
	}

	// s3 should be unchanged (no TmuxPaneID)
	s3 := sf.Agents["s3"]
	if s3.Target != "tomoro:1.0" {
		t.Errorf("s3.Target = %q, want %q (unchanged)", s3.Target, "tomoro:1.0")
	}
	if s3.Window != 1 || s3.Pane != 0 {
		t.Errorf("s3 Window.Pane = %d.%d, want 1.0 (unchanged)", s3.Window, s3.Pane)
	}
}

func TestResolveAgentTargets_DeadPane(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"alive": {Target: "tomoro:3.2", TmuxPaneID: "%90", Session: "tomoro", Window: 3, Pane: 2, State: "running"},
			"dead":  {Target: "tomoro:2.1", TmuxPaneID: "%87", Session: "tomoro", Window: 2, Pane: 1, State: "question"},
		},
	}

	// Only %90 is live; %87 is dead (not in the map)
	paneTargets := map[string]domain.PaneTarget{
		"%90": {Session: "tomoro", Window: 2, Pane: 2, Target: "tomoro:2.2"},
	}

	ResolveAgentTargets(&sf, paneTargets)

	// alive should be updated
	alive := sf.Agents["alive"]
	if alive.Window != 2 || alive.Pane != 2 {
		t.Errorf("alive Window.Pane = %d.%d, want 2.2", alive.Window, alive.Pane)
	}

	// dead should be unchanged (stale values kept as fallback)
	dead := sf.Agents["dead"]
	if dead.Window != 2 || dead.Pane != 1 {
		t.Errorf("dead Window.Pane = %d.%d, want 2.1 (unchanged)", dead.Window, dead.Pane)
	}
}

func TestResolveAgentTargets_EmptyMap(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s1": {Target: "tomoro:3.2", TmuxPaneID: "%90", Window: 3, Pane: 2, State: "running"},
		},
	}
	// Empty (non-nil) map — all agents should be left unchanged
	ResolveAgentTargets(&sf, map[string]domain.PaneTarget{})
	if sf.Agents["s1"].Window != 3 {
		t.Errorf("expected window unchanged with empty map")
	}
}

func TestResolveAgentTargets_NilMap(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s1": {Target: "tomoro:3.2", TmuxPaneID: "%90", Window: 3, Pane: 2, State: "running"},
		},
	}
	// nil paneTargets should not panic, agents left unchanged
	ResolveAgentTargets(&sf, nil)
	if sf.Agents["s1"].Window != 3 {
		t.Errorf("expected window unchanged with nil map")
	}
}

// withMockBranchRunner swaps the package-level branchRunner with a mock
// and restores the original on test cleanup.
func withMockBranchRunner(t *testing.T) *mocks.MockBranchRunner {
	t.Helper()
	m := mocks.NewMockBranchRunner(t)
	orig := branchRunner
	branchRunner = m
	t.Cleanup(func() { branchRunner = orig })
	return m
}

// mockGitBranch sets up a mock expectation: when git rev-parse is called
// for the given directory, return the given branch name. If branch is "",
// return an error (simulating a non-git directory).
func mockGitBranch(m *mocks.MockBranchRunner, dir, branch string) {
	if branch == "" {
		m.On("Output", mock.Anything, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").
			Return(nil, fmt.Errorf("not a git repo"))
	} else {
		m.On("Output", mock.Anything, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").
			Return([]byte(branch+"\n"), nil)
	}
}

func TestResolveAgentBranches(t *testing.T) {
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/valid/repo", "feat/mock-branch")
	mockGitBranch(m, "/nonexistent/path", "")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			// No WorktreeCwd → live read every refresh, even when Branch is
			// pre-populated. Vanilla source-repo agents have no pin.
			"with-cwd": {Cwd: "/valid/repo", Branch: "stale-branch", State: "running"},
			"no-cwd":   {Cwd: "", Branch: "stale", State: "running"},
			"bad-cwd":  {Cwd: "/nonexistent/path", Branch: "stale", State: "running"},
		},
	}

	ResolveAgentBranches(&sf, nil, "")

	if sf.Agents["with-cwd"].Branch != "feat/mock-branch" {
		t.Errorf("expected branch feat/mock-branch, got %q", sf.Agents["with-cwd"].Branch)
	}
	if sf.Agents["no-cwd"].Branch != "" {
		t.Errorf("expected branch cleared for no-cwd agent, got %q", sf.Agents["no-cwd"].Branch)
	}
	if sf.Agents["bad-cwd"].Branch != "" {
		t.Errorf("expected branch cleared for bad-cwd agent, got %q", sf.Agents["bad-cwd"].Branch)
	}
}

func TestResolveAgentBranches_PinnedBranchPreserved(t *testing.T) {
	// Pin semantic: when WorktreeCwd is set and Branch is already on file, the
	// stored Branch is authoritative. ResolveAgentBranches must NOT run git —
	// the recorded branch reflects what the worktree was created with, and the
	// dashboard should not flap to whatever HEAD a `git checkout` moves to.
	m := withMockBranchRunner(t)
	// Intentionally no mockGitBranch expectations. If git is called, the mock
	// fails the test via AssertExpectations.

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"pinned":           {WorktreeCwd: "/valid/worktree", Cwd: "/nonexistent/path", Branch: "feat/pinned", State: "running"},
			"pinned-wt-broken": {WorktreeCwd: "/deleted/wt", Cwd: "/valid/repo", Branch: "feat/already-pinned", State: "running"},
		},
	}

	ResolveAgentBranches(&sf, nil, "")

	if got := sf.Agents["pinned"].Branch; got != "feat/pinned" {
		t.Errorf("pinned: expected branch preserved as feat/pinned, got %q", got)
	}
	// Even when the worktree dir is gone, the pinned branch is still the right
	// label — the agent's task didn't change.
	if got := sf.Agents["pinned-wt-broken"].Branch; got != "feat/already-pinned" {
		t.Errorf("pinned-wt-broken: expected branch preserved as feat/already-pinned, got %q", got)
	}
	m.AssertExpectations(t)
}

func TestResolveAgentBranches_BackfillFromWorktree_Persists(t *testing.T) {
	// One-shot backfill: agent has WorktreeCwd set but Branch empty (legacy
	// state file written before the pin landed, or an agent stamped by the
	// fast hook that hasn't yet seen `git worktree add -b`). The next refresh
	// performs one live read against WorktreeCwd, stamps the result in
	// memory, AND persists it to the agent's JSON file so subsequent refreshes
	// skip the git call.
	m := withMockBranchRunner(t)
	worktreeDir := "/valid/worktree"
	mockGitBranch(m, worktreeDir, "feat/backfilled")

	stateDir := t.TempDir()
	sessionID := "sess-backfill"
	agent := domain.Agent{
		SessionID:   sessionID,
		WorktreeCwd: worktreeDir,
		Cwd:         "/repo",
		State:       "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentBranches(&sf, nil, stateDir)

	if got := sf.Agents[sessionID].Branch; got != "feat/backfilled" {
		t.Errorf("in-memory Branch = %q, want feat/backfilled", got)
	}

	data, err := os.ReadFile(filepath.Join(stateDir, "agents", sessionID+".json"))
	if err != nil {
		t.Fatalf("read back: %v", err)
	}
	var raw map[string]any
	if err := json.Unmarshal(data, &raw); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if raw["branch"] != "feat/backfilled" {
		t.Errorf("persisted branch = %v, want feat/backfilled", raw["branch"])
	}
}

func TestResolveAgentBranches_BackfillNoStateDir_InMemoryOnly(t *testing.T) {
	// When stateDir is empty (callers without a write path, e.g. unit tests),
	// backfill happens in memory but is not persisted. Acceptable: next
	// refresh just re-reads.
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/valid/worktree", "feat/in-mem")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"a": {SessionID: "a", WorktreeCwd: "/valid/worktree", State: "running"},
		},
	}

	ResolveAgentBranches(&sf, nil, "")

	if got := sf.Agents["a"].Branch; got != "feat/in-mem" {
		t.Errorf("Branch = %q, want feat/in-mem", got)
	}
}

func TestResolveAgentBranches_NoWorktree_LiveReadEveryRefresh(t *testing.T) {
	// Vanilla source-repo agents (no WorktreeCwd) keep live-read behavior:
	// Branch reflects the source repo's current HEAD on every refresh.
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/valid/repo", "main")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"vanilla": {Cwd: "/valid/repo", Branch: "feat/stale", State: "running"},
		},
	}

	ResolveAgentBranches(&sf, nil, "")

	// "feat/stale" was wrong (source repo is actually on main) and the live
	// read clobbers it. This is the pre-existing semantic for unpinned agents
	// and is preserved.
	if got := sf.Agents["vanilla"].Branch; got != "main" {
		t.Errorf("vanilla: expected live branch main, got %q", got)
	}
	m.AssertExpectations(t)
}

func TestResolveAgentBranches_BrokenWorktree_EmptyBranch_ClearsBranch(t *testing.T) {
	// WorktreeCwd set but git fails (dir deleted post-merge) AND Branch is
	// empty: there's no pin to preserve and no live answer. Branch ends up "".
	// Crucially, we do NOT fall back to Cwd — that would surface the source
	// repo's branch (almost always "main") and reintroduce the original bug.
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/bad/wt", "")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"broken": {WorktreeCwd: "/bad/wt", Cwd: "/valid/repo", State: "running"},
		},
	}

	ResolveAgentBranches(&sf, nil, "")

	if got := sf.Agents["broken"].Branch; got != "" {
		t.Errorf("broken: expected empty branch (no fallback to Cwd), got %q", got)
	}
}

func TestResolveAgentBranches_PaneCwdFallback(t *testing.T) {
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/pane/cwd", "feat/pane-branch")
	mockGitBranch(m, "/existing/cwd", "feat/existing-branch")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"no-cwd":  {TmuxPaneID: "%10", Branch: "stale", State: "running"},
			"has-cwd": {TmuxPaneID: "%11", Cwd: "/existing/cwd", Branch: "stale", State: "running"},
			"no-pane": {Branch: "stale", State: "running"},
		},
	}

	// Pane cwd is used to backfill an empty Cwd (display only), but never
	// to resolve branch — the agent's project dir is intentionally static.
	paneCwds := map[string]string{
		"%10": "/pane/cwd",
		"%11": "/should/not/be/used",
	}

	ResolveAgentBranches(&sf, paneCwds, "")

	if sf.Agents["no-cwd"].Cwd != "/pane/cwd" {
		t.Errorf("no-cwd: expected Cwd backfilled to /pane/cwd, got %q", sf.Agents["no-cwd"].Cwd)
	}
	if sf.Agents["no-cwd"].Branch != "feat/pane-branch" {
		t.Errorf("no-cwd: expected feat/pane-branch, got %q", sf.Agents["no-cwd"].Branch)
	}
	if sf.Agents["has-cwd"].Cwd != "/existing/cwd" {
		t.Errorf("has-cwd: stored Cwd should remain /existing/cwd, got %q", sf.Agents["has-cwd"].Cwd)
	}
	if sf.Agents["has-cwd"].Branch != "feat/existing-branch" {
		t.Errorf("has-cwd: expected branch from stored Cwd (pane cwd must not influence), got %q",
			sf.Agents["has-cwd"].Branch)
	}
	if sf.Agents["no-pane"].Branch != "" {
		t.Errorf("no-pane: expected branch cleared (no resolvable cwd), got %q", sf.Agents["no-pane"].Branch)
	}
}

func TestGitBranch(t *testing.T) {
	m := withMockBranchRunner(t)
	mockGitBranch(m, "/valid/repo", "main")
	mockGitBranch(m, "/nonexistent/path", "")

	branch := gitBranch("/valid/repo")
	if branch != "main" {
		t.Errorf("expected main, got %q", branch)
	}

	if gitBranch("/nonexistent/path") != "" {
		t.Error("expected empty branch for invalid path")
	}
}

func TestCleanStale_SkipsLivePanes(t *testing.T) {
	dir := t.TempDir()
	staleTime := "2020-01-01T00:00:00Z" // very old

	// domain.Agent with a live tmux pane — should NOT be cleaned even if stale
	writeAgentFile(t, dir, "live-agent.json", domain.Agent{
		SessionID:  "live-agent",
		TmuxPaneID: "%42",
		State:      "input",
		UpdatedAt:  staleTime,
	})

	// domain.Agent with a dead tmux pane — SHOULD be cleaned because stale + dead
	writeAgentFile(t, dir, "dead-agent.json", domain.Agent{
		SessionID:  "dead-agent",
		TmuxPaneID: "%99",
		State:      "input",
		UpdatedAt:  staleTime,
	})

	livePaneIDs := map[string]bool{"%42": true}
	CleanStale(dir, 10*60, livePaneIDs)

	// Live agent should survive
	agentsPath := filepath.Join(dir, "agents")
	if _, err := os.Stat(filepath.Join(agentsPath, "live-agent.json")); err != nil {
		t.Error("live agent was incorrectly cleaned: pane is still alive")
	}

	// Dead agent should be removed
	if _, err := os.Stat(filepath.Join(agentsPath, "dead-agent.json")); err == nil {
		t.Error("dead stale agent was not cleaned")
	}
}

func TestCleanStale_DedupSamePane(t *testing.T) {
	dir := t.TempDir()
	staleTime := "2020-01-01T00:00:00Z"
	recentTime := time.Now().UTC().Format(time.RFC3339)

	// Two agents sharing pane %42 — only the newest should survive.
	writeAgentFile(t, dir, "old-session.json", domain.Agent{
		SessionID:  "old-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  staleTime,
	})
	writeAgentFile(t, dir, "new-session.json", domain.Agent{
		SessionID:  "new-session",
		TmuxPaneID: "%42",
		State:      "running",
		UpdatedAt:  recentTime,
	})

	livePaneIDs := map[string]bool{"%42": true}
	CleanStale(dir, 10*60, livePaneIDs)

	agentsPath := filepath.Join(dir, "agents")

	// Old session should be removed (duplicate pane, older)
	if _, err := os.Stat(filepath.Join(agentsPath, "old-session.json")); err == nil {
		t.Error("old duplicate-pane agent was not cleaned")
	}

	// New session should survive (newest for this pane + pane alive)
	if _, err := os.Stat(filepath.Join(agentsPath, "new-session.json")); err != nil {
		t.Error("newest agent for live pane was incorrectly cleaned")
	}
}

func TestFormatDuration(t *testing.T) {
	if FormatDuration("") != "" {
		t.Error("expected empty for empty input")
	}
	if FormatDuration("not a date") != "" {
		t.Error("expected empty for invalid date")
	}
	// Can't easily test specific durations without mocking time,
	// but we can verify it doesn't panic on valid input
	result := FormatDuration("2020-01-01T00:00:00Z")
	if result == "" {
		t.Error("expected non-empty for valid old date")
	}
}

func TestApplyPinnedStates(t *testing.T) {
	tests := []struct {
		name      string
		state     string
		pinned    string
		wantState string
	}{
		{"idle_prompt restored to pr", "idle_prompt", "pr", "pr"},
		{"done restored to pr", "done", "pr", "pr"},
		{"question restored to pr", "question", "pr", "pr"},
		{"done restored to merged", "done", "merged", "merged"},
		{"running passes through pinned pr", "running", "pr", "running"},
		{"permission passes through pinned pr", "permission", "pr", "permission"},
		{"error passes through pinned pr", "error", "pr", "error"},
		{"plan passes through pinned pr", "plan", "pr", "plan"},
		{"no pin leaves state unchanged", "running", "", "running"},
		{"no pin idle_prompt unchanged", "idle_prompt", "", "idle_prompt"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			sf := domain.StateFile{
				Agents: map[string]domain.Agent{
					"agent": {State: tt.state, PinnedState: tt.pinned},
				},
			}
			ApplyPinnedStates(&sf)
			if got := sf.Agents["agent"].State; got != tt.wantState {
				t.Errorf("got %q, want %q", got, tt.wantState)
			}
		})
	}
}

func TestPinAgentState(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "a:0.1", State: "done", TmuxPaneID: "%1"})

	err := PinAgentState(tmp, "sess-a", "pr")
	if err != nil {
		t.Fatalf("PinAgentState failed: %v", err)
	}

	// Read back and verify
	sf := ReadState(tmp)
	agent := sf.Agents["sess-a"]
	if agent.PinnedState != "pr" {
		t.Errorf("expected pinned_state=pr, got %q", agent.PinnedState)
	}
	// Original state should be preserved
	if agent.State != "done" {
		t.Errorf("expected state=done preserved, got %q", agent.State)
	}
}

func TestPinAgentState_NonExistent(t *testing.T) {
	tmp := t.TempDir()
	writeAgentFile(t, tmp, "sess-a.json", domain.Agent{SessionID: "sess-a", Target: "a:0.1", State: "running"})

	err := PinAgentState(tmp, "nonexistent", "pr")
	if err == nil {
		t.Error("expected error for nonexistent session_id")
	}
}

func TestSortedAgents_PRAndMergedGroups(t *testing.T) {
	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"s0": {Target: "a:0.0", State: "permission", Window: 0, Pane: 0, TmuxPaneID: "%0"},
			"s1": {Target: "a:1.0", State: "running", Window: 1, Pane: 0, TmuxPaneID: "%1"},
			"s2": {Target: "a:2.0", State: "done", Window: 2, Pane: 0, TmuxPaneID: "%2"},
			"s3": {Target: "a:3.0", State: "pr", Window: 3, Pane: 0, TmuxPaneID: "%3"},
			"s4": {Target: "a:4.0", State: "merged", Window: 4, Pane: 0, TmuxPaneID: "%4"},
		},
	}

	sorted := SortedAgents(sf, "")
	expected := []string{"permission", "running", "done", "pr", "merged"}

	if len(sorted) != 5 {
		t.Fatalf("expected 5 agents, got %d", len(sorted))
	}
	for i, want := range expected {
		if sorted[i].State != want {
			t.Errorf("position %d: expected %s, got %s", i, want, sorted[i].State)
		}
	}
}

// planTestSetup creates a temp directory structure that ApplyIdleOverrides can
// resolve. Writes the JSONL at projectsDir/<slug-of-cwd>/<sessionID>.jsonl and
// returns the resolved projDir (what ResolveAgentProjDir would stamp) along
// with the agent's cwd. Tests should set Agent.ProjDir = projDir on the
// fixture; ApplyIdleOverrides reads ProjDir directly.
func planTestSetup(t *testing.T, sessionID, jsonl string) (projDir, cwd string) {
	t.Helper()
	dir := t.TempDir()
	cwd = "/test/myproject"
	slug := conversation.ProjectSlug(cwd)
	projDir = filepath.Join(dir, slug)
	os.MkdirAll(projDir, 0755)
	os.WriteFile(filepath.Join(projDir, sessionID+".jsonl"), []byte(jsonl), 0644)
	return projDir, cwd
}

func TestApplyIdleOverrides_OverridesIdlePrompt(t *testing.T) {
	sessionID := "sess-plan"
	// Realistic JSONL: ExitPlanMode is always followed by a tool_result user entry
	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"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "plan" {
		t.Errorf("expected state 'plan', got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_SkipsRunningAgents(t *testing.T) {
	sessionID := "sess-running"
	// Running agents are genuinely running — the hook-side stop-state guard
	// prevents the PostToolUse race. ApplyIdleOverrides skips them entirely
	// (the state check at line 236 is the only gate; JSONL content is irrelevant).
	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":[{"tool_use_id":"t1","type":"tool_result","content":"file.txt"}]},"timestamp":"2026-03-28T10:00:01Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "running", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "running" {
		t.Errorf("expected state 'running' unchanged (running agents should be skipped), got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_NoOverrideWithoutPlan(t *testing.T) {
	sessionID := "sess-idle"
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done"}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "idle_prompt" {
		t.Errorf("expected state 'idle_prompt' unchanged, got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_QuestionOverride(t *testing.T) {
	sessionID := "sess-question"
	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"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "question" {
		t.Errorf("expected state 'question', got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_PlanTakesPriorityOverQuestion(t *testing.T) {
	sessionID := "sess-both"
	// Both ExitPlanMode and AskUserQuestion present, no human response -> plan wins
	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":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:02Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t2","type":"tool_result","content":"Plan submitted"}]},"timestamp":"2026-03-28T10:00:03Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "plan" {
		t.Errorf("expected 'plan' to take priority over 'question', got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_OverridesPermissionWithPendingPlan(t *testing.T) {
	sessionID := "sess-perm-plan"
	// domain.Agent called ExitPlanMode, hook reported "permission" because the plan
	// selection menu looks like a permission prompt.
	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"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "permission", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "plan" {
		t.Errorf("expected state 'plan' (permission agent with pending ExitPlanMode), got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_LeavesPermissionAloneWhenNoPlan(t *testing.T) {
	sessionID := "sess-perm-real"
	// Real permission prompt — no ExitPlanMode in JSONL.
	jsonl := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Write","input":{"path":"foo.txt"}}]},"timestamp":"2026-03-28T10:00:00Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "permission", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "permission" {
		t.Errorf("expected state 'permission' unchanged (real permission, no plan), got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_QuestionAfterPlanWins(t *testing.T) {
	sessionID := "sess-interview"
	// ExitPlanMode first, then AskUserQuestion — the most recent blocking tool
	// should win. Only tool_results between them (no human text), so both are
	// technically "pending", but AskUserQuestion is the later action.
	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":"tool_use","id":"t2","name":"AskUserQuestion","input":{"question":"Before I update the plan, what framework do you prefer?"}}]},"timestamp":"2026-03-28T10:00:02Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t2","type":"tool_result","content":"Waiting for user response"}]},"timestamp":"2026-03-28T10:00:03Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "question" {
		t.Errorf("expected 'question' (AskUserQuestion after ExitPlanMode), got %q", sf.Agents[sessionID].State)
	}
}

func TestApplyIdleOverrides_PlanAfterUnansweredQuestionWins(t *testing.T) {
	sessionID := "sess-plan-after-q"
	// AskUserQuestion first (only tool_result after), then ExitPlanMode —
	// the most recent blocking tool wins, so plan takes precedence.
	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":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"ExitPlanMode","input":{}}]},"timestamp":"2026-03-28T10:00:02Z"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"t2","type":"tool_result","content":"Plan submitted"}]},"timestamp":"2026-03-28T10:00:03Z"}
`
	projDir, cwd := planTestSetup(t, sessionID, jsonl)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, State: "idle_prompt", Cwd: cwd, ProjDir: projDir},
		},
	}

	ApplyIdleOverrides(&sf)

	if sf.Agents[sessionID].State != "plan" {
		t.Errorf("expected 'plan' (ExitPlanMode after AskUserQuestion), got %q", sf.Agents[sessionID].State)
	}
}

// projDirTestSetup creates projectsDir/<slug>/<sid>.jsonl for ResolveAgentProjDir
// tests. Returns the projectsDir.
func projDirTestSetup(t *testing.T, slugCwd, sessionID string) string {
	t.Helper()
	dir := t.TempDir()
	slug := conversation.ProjectSlug(slugCwd)
	if err := os.MkdirAll(filepath.Join(dir, slug), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, slug, sessionID+".jsonl"), []byte(""), 0o644); err != nil {
		t.Fatalf("write jsonl: %v", err)
	}
	return dir
}

// mockTopologySource sets up a runner expectation: rev-parse calls for
// `seed` resolve to the given source/worktree pair.
func mockTopologySource(m *mocks.MockBranchRunner, seed, worktree, source string) {
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse", "--show-toplevel").
		Return([]byte(worktree+"\n"), nil).Maybe()
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse", "--path-format=absolute", "--git-common-dir").
		Return([]byte(filepath.Join(source, ".git")+"\n"), nil).Maybe()
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse", "--show-superproject-working-tree").
		Return([]byte("\n"), nil).Maybe()
}

func TestResolveAgentProjDir_AgentCwdSlugMatches(t *testing.T) {
	// Pane 4.2 shape: agent.Cwd matches the JSONL location's slug.
	sessionID := "sess-aligned"
	projectsDir := projDirTestSetup(t, "/repo", sessionID)
	wantProjDir := filepath.Join(projectsDir, conversation.ProjectSlug("/repo"))

	m := withMockBranchRunner(t)
	mockTopologySource(m, "/repo", "/repo", "/repo")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, Cwd: "/repo"},
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents[sessionID].ProjDir; got != wantProjDir {
		t.Errorf("ProjDir = %q, want %q", got, wantProjDir)
	}
}

func TestResolveAgentProjDir_FallsThroughToTopologySource(t *testing.T) {
	// Pane 4.1 shape: agent.Cwd is the worktree path; JSONL is at the
	// source-repo slug. Only top.Source produces a hit.
	sessionID := "sess-mismatch"
	projectsDir := projDirTestSetup(t, "/repo", sessionID)
	wantProjDir := filepath.Join(projectsDir, conversation.ProjectSlug("/repo"))

	m := withMockBranchRunner(t)
	mockTopologySource(m, "/wt/feat", "/wt/feat", "/repo")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, Cwd: "/wt/feat", WorktreeCwd: "/wt/feat"},
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents[sessionID].ProjDir; got != wantProjDir {
		t.Errorf("ProjDir = %q, want %q (should fall through to top.Source)", got, wantProjDir)
	}
}

func TestResolveAgentProjDir_NoJSONL_LeavesProjDirEmpty(t *testing.T) {
	sessionID := "sess-no-jsonl"
	projectsDir := t.TempDir() // no JSONL written

	m := withMockBranchRunner(t)
	mockTopologySource(m, "/repo", "/repo", "/repo")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, Cwd: "/repo"},
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents[sessionID].ProjDir; got != "" {
		t.Errorf("ProjDir = %q, want empty", got)
	}
}

func TestResolveAgentProjDir_EmptySessionID_BackfilledByLocate(t *testing.T) {
	// Empty SessionID + Cwd whose session metadata exists — Locate finds
	// the SessionID, and ResolveAgentProjDir then resolves ProjDir.
	sessionID := "sess-discovered"
	projectsDir := projDirTestSetup(t, "/repo", sessionID)
	wantProjDir := filepath.Join(projectsDir, conversation.ProjectSlug("/repo"))

	sessionsDir := t.TempDir()
	sessFile := map[string]any{
		"sessionId": sessionID,
		"cwd":       "/repo",
		"startedAt": int64(1000),
	}
	data, _ := json.Marshal(sessFile)
	os.WriteFile(filepath.Join(sessionsDir, "1.json"), data, 0o644)

	m := withMockBranchRunner(t)
	mockTopologySource(m, "/repo", "/repo", "/repo")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"agent-1": {Cwd: "/repo"}, // no SessionID
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, sessionsDir)

	got := sf.Agents["agent-1"]
	if got.SessionID != sessionID {
		t.Errorf("SessionID = %q, want %q (Locate should backfill)", got.SessionID, sessionID)
	}
	if got.ProjDir != wantProjDir {
		t.Errorf("ProjDir = %q, want %q", got.ProjDir, wantProjDir)
	}
}

func TestResolveAgentProjDir_NoSessionIDAndLocateFails_LeavesEmpty(t *testing.T) {
	projectsDir := projDirTestSetup(t, "/repo", "sess-1")

	m := withMockBranchRunner(t)
	mockTopologySource(m, "/repo", "/repo", "/repo")

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"agent-1": {Cwd: "/repo"}, // no SessionID, no sessions metadata either
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents["agent-1"]; got.SessionID != "" || got.ProjDir != "" {
		t.Errorf("expected empty SessionID + ProjDir, got %+v", got)
	}
}

func TestResolveAgentProjDir_GitResolveFails_StillTriesAgentCwd(t *testing.T) {
	// repo.Resolve fails (path not in any git repo), but the agent's stamped
	// Cwd still matches the JSONL slug, so PickProjDir succeeds without
	// topology candidates.
	sessionID := "sess-no-git"
	projectsDir := projDirTestSetup(t, "/notagit", sessionID)
	wantProjDir := filepath.Join(projectsDir, conversation.ProjectSlug("/notagit"))

	m := withMockBranchRunner(t)
	m.On("Output", mock.Anything, "git", "-C", "/notagit", "rev-parse", "--show-toplevel").
		Return(nil, fmt.Errorf("not a git repo")).Maybe()

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, Cwd: "/notagit"},
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents[sessionID].ProjDir; got != wantProjDir {
		t.Errorf("ProjDir = %q, want %q (Cwd candidate should match even if topology fails)", got, wantProjDir)
	}
}

func TestResolveAgentProjDir_DeletedWorktree_FallsBackToScan(t *testing.T) {
	// Reproducer from pane 4.1 (post-merge):
	//   - agent.Cwd = /wt/feat (worktree path) — directory has been deleted
	//   - JSONL still on disk at the source-repo slug
	//   - repo.Resolve fails because /wt/feat doesn't exist
	//   - Topology candidates are empty; agent.Cwd's slug doesn't match
	//
	// Without the FindProjDirByScan fallback, ProjDir would resolve to "".
	// The JSONL is reachable; we should find it.
	sessionID := "sess-deleted-wt"
	projectsDir := projDirTestSetup(t, "/repo", sessionID) // JSONL at source slug
	wantProjDir := filepath.Join(projectsDir, conversation.ProjectSlug("/repo"))

	m := withMockBranchRunner(t)
	m.On("Output", mock.Anything, "git", "-C", "/wt/feat", "rev-parse", "--show-toplevel").
		Return(nil, fmt.Errorf("not a git repo")).Maybe()

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			sessionID: {SessionID: sessionID, Cwd: "/wt/feat", WorktreeCwd: "/wt/feat"},
		},
	}

	ResolveAgentProjDir(&sf, projectsDir, t.TempDir())

	if got := sf.Agents[sessionID].ProjDir; got != wantProjDir {
		t.Errorf("ProjDir = %q, want %q (scan fallback should locate the JSONL)", got, wantProjDir)
	}
}

// writeWorktreeAddJSONL writes a minimal JSONL file containing one
// `git worktree add <path>` assistant Bash tool_use entry, mirroring
// conversation.LastGitWorktreeAdd's expected schema. Returns projDir.
func writeWorktreeAddJSONL(t *testing.T, sessionID, cmd string) string {
	t.Helper()
	projDir := t.TempDir()
	line := fmt.Sprintf(
		`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":%q}}]}}`,
		cmd,
	)
	path := filepath.Join(projDir, sessionID+".jsonl")
	if err := os.WriteFile(path, []byte(line+"\n"), 0o644); err != nil {
		t.Fatalf("write jsonl: %v", err)
	}
	return projDir
}

// mockRepoResolve sets up the three git calls that repo.Resolve makes for
// a seed path: rev-parse --show-toplevel, --git-common-dir, and
// --show-superproject-working-tree.
func mockRepoResolve(m *mocks.MockBranchRunner, seed, worktree, source string) {
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse", "--show-toplevel").
		Return([]byte(worktree+"\n"), nil)
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse",
		"--path-format=absolute", "--git-common-dir").
		Return([]byte(filepath.Join(source, ".git")+"\n"), nil)
	m.On("Output", mock.Anything, "git", "-C", seed, "rev-parse",
		"--show-superproject-working-tree").
		Return([]byte("\n"), nil)
}

// seedAgentJSON writes a minimal agent state file to stateDir/agents/<id>.json
// containing all required fields for round-trip read/write.
func seedAgentJSON(t *testing.T, stateDir string, agent domain.Agent) {
	t.Helper()
	agentsDir := filepath.Join(stateDir, "agents")
	if err := os.MkdirAll(agentsDir, 0o755); err != nil {
		t.Fatalf("mkdir agents: %v", err)
	}
	data, err := json.Marshal(agent)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if err := os.WriteFile(filepath.Join(agentsDir, agent.SessionID+".json"), data, 0o600); err != nil {
		t.Fatalf("write agent file: %v", err)
	}
}

func TestResolveAgentWorktree_StampsAndWritesBack(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-stamp"
	candidate := "/wt/feat"
	cwd := "/repo/src"
	source := "/repo/src"

	projDir := writeWorktreeAddJSONL(t, sessionID, "git worktree add "+candidate)

	// Validation: rev-parse --show-toplevel returns the candidate itself.
	m.On("Output", mock.Anything, "git", "-C", candidate, "rev-parse", "--show-toplevel").
		Return([]byte(candidate+"\n"), nil)

	// repo.Resolve(agent.Cwd) — seed is cwd.
	mockRepoResolve(m, cwd, source, source)
	// repo.Resolve(candidate) — seed is candidate, source matches.
	mockRepoResolve(m, candidate, candidate, source)

	// Self-heal also stamps branch alongside the recovered worktree_cwd, so
	// the agent doesn't have to wait for ResolveAgentBranches' backfill on a
	// later refresh. One git call, same atomic write.
	mockGitBranch(m, candidate, "feat/recovered")

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       cwd,
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != candidate {
		t.Errorf("in-memory WorktreeCwd = %q, want %q", got, candidate)
	}
	if got := sf.Agents[sessionID].Branch; got != "feat/recovered" {
		t.Errorf("in-memory Branch = %q, want feat/recovered", got)
	}

	// Verify on-disk persistence — both fields written in the same update.
	data, err := os.ReadFile(filepath.Join(stateDir, "agents", sessionID+".json"))
	if err != nil {
		t.Fatalf("read back: %v", err)
	}
	var raw map[string]any
	if err := json.Unmarshal(data, &raw); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if raw["worktree_cwd"] != candidate {
		t.Errorf("persisted worktree_cwd = %v, want %q", raw["worktree_cwd"], candidate)
	}
	if raw["branch"] != "feat/recovered" {
		t.Errorf("persisted branch = %v, want feat/recovered", raw["branch"])
	}
}

func TestResolveAgentWorktree_StampsWorktreeButBranchEmpty_WhenGitFails(t *testing.T) {
	// Self-heal recovers worktree_cwd even when the branch lookup fails (e.g.
	// detached HEAD on the freshly-added worktree). Branch stamping is
	// best-effort: worktree_cwd is the primary pin, branch fills in later.
	m := withMockBranchRunner(t)
	sessionID := "sess-stamp-no-branch"
	candidate := "/wt/feat"
	cwd := "/repo/src"
	source := "/repo/src"

	projDir := writeWorktreeAddJSONL(t, sessionID, "git worktree add "+candidate)

	m.On("Output", mock.Anything, "git", "-C", candidate, "rev-parse", "--show-toplevel").
		Return([]byte(candidate+"\n"), nil)
	mockRepoResolve(m, cwd, source, source)
	mockRepoResolve(m, candidate, candidate, source)
	mockGitBranch(m, candidate, "") // branch lookup fails (mock returns error)

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       cwd,
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != candidate {
		t.Errorf("in-memory WorktreeCwd = %q, want %q (worktree should still stamp)", got, candidate)
	}
	if got := sf.Agents[sessionID].Branch; got != "" {
		t.Errorf("in-memory Branch = %q, want empty (branch lookup failed)", got)
	}
}

func TestResolveAgentWorktree_NoJSONLMatch_NoOp(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-no-jsonl"

	// projDir exists but has no matching command.
	projDir := writeWorktreeAddJSONL(t, sessionID, "git status")

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       "/repo",
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != "" {
		t.Errorf("WorktreeCwd = %q, want empty (no JSONL match)", got)
	}
	// Verify mock had no unexpected calls.
	m.AssertExpectations(t)
}

func TestResolveAgentWorktree_PreStamped_FastPath(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-pre"

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID:   sessionID,
		Cwd:         "/repo",
		WorktreeCwd: "/already/stamped",
		ProjDir:     "/tmp/proj",
		State:       "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != "/already/stamped" {
		t.Errorf("WorktreeCwd should be unchanged, got %q", got)
	}
	m.AssertExpectations(t) // no git calls expected
}

func TestResolveAgentWorktree_SourceRepoMismatch_NoStamp(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-mismatch"
	candidate := "/other/wt"
	cwd := "/repo/src"

	projDir := writeWorktreeAddJSONL(t, sessionID, "git worktree add "+candidate)

	m.On("Output", mock.Anything, "git", "-C", candidate, "rev-parse", "--show-toplevel").
		Return([]byte(candidate+"\n"), nil)

	// agent's source is /repo/src.
	mockRepoResolve(m, cwd, "/repo/src", "/repo/src")
	// candidate's source is /other (different repo).
	mockRepoResolve(m, candidate, candidate, "/other")

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       cwd,
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != "" {
		t.Errorf("WorktreeCwd = %q, want empty (source repos differ)", got)
	}
}

func TestResolveAgentWorktree_RevParseFails_NoStamp(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-revparse-fail"
	candidate := "/deleted/wt"

	projDir := writeWorktreeAddJSONL(t, sessionID, "git worktree add "+candidate)

	m.On("Output", mock.Anything, "git", "-C", candidate, "rev-parse", "--show-toplevel").
		Return(nil, fmt.Errorf("not a git repo"))

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       "/repo",
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != "" {
		t.Errorf("WorktreeCwd = %q, want empty (rev-parse failed)", got)
	}
}

func TestResolveAgentWorktree_EmptySessionID_Skip(t *testing.T) {
	m := withMockBranchRunner(t)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"no-sess": {Cwd: "/repo", ProjDir: "/proj", State: "running"},
		},
	}
	ResolveAgentWorktree(&sf, t.TempDir())

	if got := sf.Agents["no-sess"].WorktreeCwd; got != "" {
		t.Errorf("WorktreeCwd = %q, want empty (no session id)", got)
	}
	m.AssertExpectations(t) // no git calls expected
}

func TestResolveAgentWorktree_EmptyProjDir_Skip(t *testing.T) {
	m := withMockBranchRunner(t)

	sf := domain.StateFile{
		Agents: map[string]domain.Agent{
			"sess-1": {SessionID: "sess-1", Cwd: "/repo", State: "running"},
		},
	}
	ResolveAgentWorktree(&sf, t.TempDir())

	if got := sf.Agents["sess-1"].WorktreeCwd; got != "" {
		t.Errorf("WorktreeCwd = %q, want empty (no proj dir)", got)
	}
	m.AssertExpectations(t) // no git calls expected
}

func TestResolveAgentWorktree_RelativePath_ResolvedAgainstCwd(t *testing.T) {
	m := withMockBranchRunner(t)
	sessionID := "sess-rel"
	cwd := "/repo/src"
	resolved := "/repo/worktrees/feat"

	projDir := writeWorktreeAddJSONL(t, sessionID, "git worktree add ../worktrees/feat")

	m.On("Output", mock.Anything, "git", "-C", resolved, "rev-parse", "--show-toplevel").
		Return([]byte(resolved+"\n"), nil)
	mockRepoResolve(m, cwd, "/repo/src", "/repo/src")
	mockRepoResolve(m, resolved, resolved, "/repo/src")
	mockGitBranch(m, resolved, "feat/rel")

	stateDir := t.TempDir()
	agent := domain.Agent{
		SessionID: sessionID,
		Cwd:       cwd,
		ProjDir:   projDir,
		State:     "running",
	}
	seedAgentJSON(t, stateDir, agent)

	sf := domain.StateFile{Agents: map[string]domain.Agent{sessionID: agent}}
	ResolveAgentWorktree(&sf, stateDir)

	if got := sf.Agents[sessionID].WorktreeCwd; got != resolved {
		t.Errorf("WorktreeCwd = %q, want %q (relative resolved against cwd)", got, resolved)
	}
}
