package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/jobs"
	"reasonix/internal/store"
)

func occupyReadFileWithTimeoutSlots(t *testing.T) func() {
	t.Helper()
	filled := 0
	for filled < cap(readFileWithTimeoutSlots) {
		readFileWithTimeoutSlots <- struct{}{}
		filled++
	}
	released := false
	release := func() {
		if released {
			return
		}
		released = true
		for range filled {
			<-readFileWithTimeoutSlots
		}
	}
	t.Cleanup(release)
	return release
}

// loadSessionTitles

func TestLoadSessionTitlesMissing(t *testing.T) {
	dir := t.TempDir()
	m := loadSessionTitles(dir)
	if len(m) != 0 {
		t.Errorf("missing file should return empty map, got %v", m)
	}
}

func TestLoadSessionTitlesCorrupt(t *testing.T) {
	dir := t.TempDir()
	os.WriteFile(sessionTitlesPath(dir), []byte(`{not json`), 0o644)
	m := loadSessionTitles(dir)
	if len(m) != 0 {
		t.Errorf("corrupt file should return empty map, got %v", m)
	}
}

func TestLoadSessionTitlesValid(t *testing.T) {
	dir := t.TempDir()
	data := map[string]string{"session-1.jsonl": "My Session", "session-2.jsonl": "Another"}
	b, _ := json.Marshal(data)
	os.WriteFile(sessionTitlesPath(dir), b, 0o644)
	m := loadSessionTitles(dir)
	if m["session-1.jsonl"] != "My Session" || m["session-2.jsonl"] != "Another" {
		t.Errorf("loaded = %v", m)
	}
}

// saveSessionTitles

func TestSaveSessionTitlesCreatesDir(t *testing.T) {
	dir := filepath.Join(t.TempDir(), "nested", "sessions")
	m := map[string]string{"a.jsonl": "title A"}
	if err := saveSessionTitles(dir, m); err != nil {
		t.Fatalf("save: %v", err)
	}
	// Verify file exists and is valid JSON.
	b, err := os.ReadFile(sessionTitlesPath(dir))
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	var decoded map[string]string
	if err := json.Unmarshal(b, &decoded); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if decoded["a.jsonl"] != "title A" {
		t.Errorf("decoded = %v", decoded)
	}
}

func TestSaveSessionTitlesRoundTrip(t *testing.T) {
	dir := t.TempDir()
	original := map[string]string{"s1.jsonl": "First", "s2.jsonl": "Second"}
	if err := saveSessionTitles(dir, original); err != nil {
		t.Fatalf("save: %v", err)
	}
	loaded := loadSessionTitles(dir)
	if loaded["s1.jsonl"] != "First" || loaded["s2.jsonl"] != "Second" {
		t.Errorf("round-trip = %v", loaded)
	}
}

// setSessionTitle

func TestSetSessionTitle(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "my-session.jsonl")

	// Set a title.
	if err := setSessionTitle(dir, sessionPath, "Custom Title"); err != nil {
		t.Fatalf("set: %v", err)
	}
	m := loadSessionTitles(dir)
	if m["my-session.jsonl"] != "Custom Title" {
		t.Errorf("title = %q", m["my-session.jsonl"])
	}

	// Clear the title (empty string).
	if err := setSessionTitle(dir, sessionPath, ""); err != nil {
		t.Fatalf("clear: %v", err)
	}
	m = loadSessionTitles(dir)
	if _, ok := m["my-session.jsonl"]; ok {
		t.Error("cleared title should be removed from map")
	}
}

func TestSetSessionTitleTrimsWhitespace(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "s.jsonl")
	if err := setSessionTitle(dir, sessionPath, "  trimmed  "); err != nil {
		t.Fatalf("set: %v", err)
	}
	m := loadSessionTitles(dir)
	if m["s.jsonl"] != "trimmed" {
		t.Errorf("title = %q, want trimmed", m["s.jsonl"])
	}
}

func TestSetSessionTitlePreservesExistingTitlesWhenTimedReadSlotsFull(t *testing.T) {
	dir := t.TempDir()
	if err := saveSessionTitles(dir, map[string]string{"old.jsonl": "Old"}); err != nil {
		t.Fatalf("save old title: %v", err)
	}
	sessionPath := filepath.Join(dir, "new.jsonl")
	release := occupyReadFileWithTimeoutSlots(t)
	if err := setSessionTitle(dir, sessionPath, "New"); err != nil {
		t.Fatalf("setSessionTitle: %v", err)
	}
	release()

	m := loadSessionTitles(dir)
	if got := m["old.jsonl"]; got != "Old" {
		t.Fatalf("old title = %q, want Old (all titles: %v)", got, m)
	}
	if got := m["new.jsonl"]; got != "New" {
		t.Fatalf("new title = %q, want New (all titles: %v)", got, m)
	}
}

// deleteSessionFile

func TestDeleteSessionFile(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	os.WriteFile(sessionPath, []byte("data"), 0o644)
	metaPath := sessionPath + ".meta"
	os.WriteFile(metaPath, []byte("{}"), 0o644)
	goalPath := store.SessionGoalState(sessionPath)
	os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
	eventLogPath := store.SessionEventLog(sessionPath)
	os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
	eventIndexPath := store.SessionEventIndex(sessionPath)
	os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
	conflictLogPath := store.SessionConflictLog(sessionPath)
	os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
	recoveryPath := store.SessionRecoveryState(sessionPath)
	os.WriteFile(recoveryPath, []byte(`{"tasks":{"root":{"phase":"diagnosing"}}}`+"\n"), 0o600)
	telemetryPath := sessionTelemetryPath(sessionPath)
	os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
	lockPath := store.SessionLockFile(sessionPath)
	os.WriteFile(lockPath, nil, 0o644)
	leaseLockPath := store.SessionLeaseLock(sessionPath)
	os.WriteFile(leaseLockPath, nil, 0o644)
	leaseInfoPath := store.SessionLeaseInfo(sessionPath)
	os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
	ckptDir := filepath.Join(dir, "session.ckpt")
	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
		t.Fatalf("mkdir ckpt: %v", err)
	}
	os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644)
	jobsDir := jobs.ArtifactDir(sessionPath)
	if err := os.MkdirAll(jobsDir, 0o755); err != nil {
		t.Fatalf("mkdir jobs: %v", err)
	}
	if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
		t.Fatalf("write job artifact: %v", err)
	}

	// Set a title first.
	setSessionTitle(dir, sessionPath, "My Title")
	if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
		t.Fatalf("record display: %v", err)
	}

	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("delete: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	trashMetaPath := trashPath + ".meta"
	trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
	trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
	trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
	trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
	trashRecoveryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.recovery.json")
	trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
	trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
	trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")

	// File should be moved out of the active session list.
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Error("session file should be removed from active sessions")
	}
	if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
		t.Error("session meta should be removed from active sessions")
	}
	if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
		t.Error("session goal state should be removed from active sessions")
	}
	if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
		t.Error("session event log should be removed from active sessions")
	}
	if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
		t.Error("session event index should be removed from active sessions")
	}
	if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
		t.Error("session conflict log should be removed from active sessions")
	}
	if _, err := os.Stat(recoveryPath); !os.IsNotExist(err) {
		t.Error("session recovery state should be removed from active sessions")
	}
	if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
		t.Error("session telemetry should be removed from active sessions")
	}
	if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
		t.Error("session lock should be removed from active sessions")
	}
	if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
		t.Error("session lease lock should be removed from active sessions")
	}
	if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
		t.Error("session lease info should be removed from active sessions")
	}
	if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
		t.Error("session checkpoints should be removed from active sessions")
	}
	if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
		t.Error("session jobs should be removed from active sessions")
	}
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("session file should be in trash: %v", err)
	}
	if _, err := os.Stat(trashMetaPath); err != nil {
		t.Fatalf("session meta should be in trash: %v", err)
	}
	if _, err := os.Stat(trashGoalPath); err != nil {
		t.Fatalf("session goal state should be in trash: %v", err)
	}
	if _, err := os.Stat(trashEventLogPath); err != nil {
		t.Fatalf("session event log should be in trash: %v", err)
	}
	if _, err := os.Stat(trashEventIndexPath); err != nil {
		t.Fatalf("session event index should be in trash: %v", err)
	}
	if _, err := os.Stat(trashConflictLogPath); err != nil {
		t.Fatalf("session conflict log should be in trash: %v", err)
	}
	if _, err := os.Stat(trashRecoveryPath); err != nil {
		t.Fatalf("session recovery state should be in trash: %v", err)
	}
	if _, err := os.Stat(trashTelemetryPath); err != nil {
		t.Fatalf("session telemetry should be in trash: %v", err)
	}
	for _, p := range []string{
		filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
		filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
		filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
	} {
		if _, err := os.Stat(p); !os.IsNotExist(err) {
			t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
		}
	}
	if _, err := os.Stat(trashCkptDir); err != nil {
		t.Fatalf("session checkpoints should be in trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(trashJobsDir, "bash-1.log")); err != nil {
		t.Fatalf("session jobs should be in trash: %v", err)
	}
	// Title/display should be retained until permanent deletion.
	m := loadSessionTitles(dir)
	if m["session.jsonl"] != "My Title" {
		t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
	}
	if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
		t.Errorf("display sidecar should be retained in trash, got %q", got)
	}
}

func TestReconcileDesktopCleanupPendingDeleteMovesArtifactsToTrash(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "pending.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"pending"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
		t.Fatal(err)
	}

	if err := reconcileDesktopCleanupPending(dir); err != nil {
		t.Fatalf("reconcileDesktopCleanupPending: %v", err)
	}

	trashPath := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jsonl")
	trashJobsDir := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jobs")
	for _, p := range []string{
		trashPath,
		filepath.Join(trashJobsDir, "job.log"),
	} {
		if _, err := os.Stat(p); err != nil {
			t.Fatalf("expected trashed artifact %s: %v", p, err)
		}
	}
	for _, p := range []string{sessionPath, jobs.ArtifactDir(sessionPath), agent.CleanupPendingPath(sessionPath)} {
		if _, err := os.Stat(p); !os.IsNotExist(err) {
			t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
		}
	}
}

func TestReconcileDesktopCleanupPendingDeleteReusesExistingTrashDir(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "partial.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"partial"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	itemDir := filepath.Join(sessionTrashPath(dir), "partial.jsonl")
	if err := os.MkdirAll(itemDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
		t.Fatal(err)
	}

	if err := reconcileDesktopCleanupPending(dir); err != nil {
		t.Fatalf("reconcileDesktopCleanupPending: %v", err)
	}

	if _, err := os.Stat(filepath.Join(itemDir, "partial.jsonl")); err != nil {
		t.Fatalf("session should be moved into existing trash dir: %v", err)
	}
	if _, err := os.Stat(agent.CleanupPendingPath(sessionPath)); !os.IsNotExist(err) {
		t.Fatalf("cleanup marker still exists after reconciliation (err=%v)", err)
	}
}

func TestReconcileDesktopCleanupPendingDeleteMovesRemainingSidecars(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "sidecars.jsonl")
	if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	goalPath := store.SessionGoalState(sessionPath)
	if err := os.WriteFile(goalPath, []byte(`{"goal":"finish"}`), 0o644); err != nil {
		t.Fatal(err)
	}
	telemetryPath := sessionTelemetryPath(sessionPath)
	if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	ckptDir := filepath.Join(dir, "sidecars.ckpt")
	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
		t.Fatal(err)
	}
	ref := "sa_20260102_030405_000000000_aabbccddeeff"
	writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
	itemDir := filepath.Join(sessionTrashPath(dir), "sidecars.jsonl")
	if err := os.MkdirAll(itemDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(itemDir, "sidecars.jsonl"), []byte(`{"role":"user","content":"sidecars"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
		t.Fatal(err)
	}

	if err := reconcileDesktopCleanupPending(dir); err != nil {
		t.Fatalf("reconcileDesktopCleanupPending: %v", err)
	}

	for _, p := range []string{
		filepath.Join(itemDir, "sidecars.jsonl.meta"),
		filepath.Join(itemDir, "sidecars.goal-state.json"),
		filepath.Join(itemDir, "sidecars.jsonl.telemetry.json"),
		filepath.Join(itemDir, "sidecars.ckpt", "1.json"),
		filepath.Join(itemDir, "sidecars.jobs", "job.log"),
		filepath.Join(itemDir, "subagents", ref+".jsonl"),
		filepath.Join(itemDir, "subagents", ref+".meta.json"),
	} {
		if _, err := os.Stat(p); err != nil {
			t.Fatalf("expected remaining artifact %s to move into trash: %v", p, err)
		}
	}
	for _, p := range []string{
		sessionPath + ".meta",
		goalPath,
		telemetryPath,
		ckptDir,
		jobs.ArtifactDir(sessionPath),
		filepath.Join(dir, "subagents", ref+".jsonl"),
		filepath.Join(dir, "subagents", ref+".meta.json"),
		agent.CleanupPendingPath(sessionPath),
	} {
		if _, err := os.Stat(p); !os.IsNotExist(err) {
			t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
		}
	}
}

func TestDeleteSessionFileMovesOwnedSubagentsToTrash(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	os.WriteFile(sessionPath, []byte("data"), 0o644)
	writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_aabbccddeeff", agent.BranchID(sessionPath))
	writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_112233445566", "other-parent")

	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("delete: %v", err)
	}

	ownedJSONL := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.jsonl")
	ownedMeta := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.meta.json")
	if _, err := os.Stat(ownedJSONL); !os.IsNotExist(err) {
		t.Fatalf("owned subagent jsonl should be moved out of active dir, stat err = %v", err)
	}
	if _, err := os.Stat(ownedMeta); !os.IsNotExist(err) {
		t.Fatalf("owned subagent meta should be moved out of active dir, stat err = %v", err)
	}
	trashSubagentDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents")
	if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.jsonl")); err != nil {
		t.Fatalf("owned subagent jsonl should be in trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.meta.json")); err != nil {
		t.Fatalf("owned subagent meta should be in trash: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.jsonl")); err != nil {
		t.Fatalf("unowned subagent jsonl should remain active: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.meta.json")); err != nil {
		t.Fatalf("unowned subagent meta should remain active: %v", err)
	}
}

func TestDeleteSessionFileNoTitle(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "no-title.jsonl")
	os.WriteFile(sessionPath, []byte("data"), 0o644)

	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Error("session file should be removed from active sessions")
	}
	if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
		t.Fatalf("session file should be in trash: %v", err)
	}
}

func TestRestoreTrashedSessionFile(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	goalPath := store.SessionGoalState(sessionPath)
	if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
		t.Fatal(err)
	}
	eventLogPath := store.SessionEventLog(sessionPath)
	if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	eventIndexPath := store.SessionEventIndex(sessionPath)
	if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
		t.Fatal(err)
	}
	damagedPath := store.SessionEventLogDamaged(sessionPath)
	if err := os.WriteFile(damagedPath, []byte(`{"damaged_tail":true}`+"\ntorn bytes\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	conflictLogPath := store.SessionConflictLog(sessionPath)
	if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	telemetryPath := sessionTelemetryPath(sessionPath)
	if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	ckptDir := filepath.Join(dir, "session.ckpt")
	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	jobsDir := jobs.ArtifactDir(sessionPath)
	if err := os.MkdirAll(jobsDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
		t.Fatal(err)
	}

	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	// The raw salvage sidecar holds session content; deletion must move it out
	// of the live directory with the rest of the transcript artifacts (#6613
	// review: it used to be left behind, and purging the trash never removed it).
	if _, err := os.Stat(damagedPath); !os.IsNotExist(err) {
		t.Fatalf("damaged salvage sidecar should leave the live dir on delete, stat err = %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl.damaged")); err != nil {
		t.Fatalf("damaged salvage sidecar should be in the trash: %v", err)
	}
	if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
		t.Fatalf("restore: %v", err)
	}

	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("session file should be restored: %v", err)
	}
	if _, err := os.Stat(sessionPath + ".meta"); err != nil {
		t.Fatalf("session meta should be restored: %v", err)
	}
	if _, err := os.Stat(goalPath); err != nil {
		t.Fatalf("session goal state should be restored: %v", err)
	}
	if _, err := os.Stat(eventLogPath); err != nil {
		t.Fatalf("session event log should be restored: %v", err)
	}
	if _, err := os.Stat(eventIndexPath); err != nil {
		t.Fatalf("session event index should be restored: %v", err)
	}
	if _, err := os.Stat(damagedPath); err != nil {
		t.Fatalf("damaged salvage sidecar should be restored: %v", err)
	}
	if _, err := os.Stat(conflictLogPath); err != nil {
		t.Fatalf("session conflict log should be restored: %v", err)
	}
	if _, err := os.Stat(telemetryPath); err != nil {
		t.Fatalf("session telemetry should be restored: %v", err)
	}
	if _, err := os.Stat(ckptDir); err != nil {
		t.Fatalf("session checkpoints should be restored: %v", err)
	}
	if _, err := os.Stat(filepath.Join(jobsDir, "bash-1.log")); err != nil {
		t.Fatalf("session jobs should be restored: %v", err)
	}
	if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
		t.Fatalf("trash item should be removed after restore, stat err = %v", err)
	}
	if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
		t.Fatalf("title should survive restore, got %q", got)
	}
}

func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "restore-stub.jsonl")
	if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
		t.Fatalf("write live stub: %v", err)
	}

	if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
		t.Fatalf("restore should replace empty live stub: %v", err)
	}
	if got, err := os.ReadFile(sessionPath); err != nil || string(got) != "data" {
		t.Fatalf("restored session = %q, %v; want trash data", string(got), err)
	}
	if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
		t.Fatalf("trash item should be removed after restore, err=%v", err)
	}
}

func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "restore-unique.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash old: %v", err)
	}
	fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write new live session: %v", err)
	}
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash new with fixed trash conflict: %v", err)
	}

	trashed, err := listTrashedSessionFiles(dir)
	if err != nil {
		t.Fatalf("list trash: %v", err)
	}
	var uniqueTrashPath string
	for _, candidate := range trashed {
		if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
			uniqueTrashPath = candidate
			break
		}
	}
	if uniqueTrashPath == "" {
		t.Fatalf("unique trash path not listed, got %#v", trashed)
	}
	if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
		t.Fatalf("restore unique trash item: %v", err)
	}
	if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
		t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
	}
	if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
		t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
	}
	if _, err := os.Stat(fixedTrashPath); err != nil {
		t.Fatalf("original fixed trash item should remain: %v", err)
	}
}

func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "discardable-live.jsonl")
	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
		t.Fatalf("write live stub: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
		t.Fatalf("create trash dir: %v", err)
	}
	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write trash session: %v", err)
	}

	if err := validateSessionTrashTarget(dir, sessionPath, filepath.Base(sessionPath)); err != nil {
		t.Fatalf("validateSessionTrashTarget: %v", err)
	}
	if _, err := os.Stat(sessionPath); err != nil {
		t.Fatalf("validation should not remove live stub: %v", err)
	}
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("validation should keep existing trash: %v", err)
	}
}

func TestRestoreTrashedSessionFileRejectsNonEmptyLiveConflict(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "restore-conflict.jsonl")
	if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"live"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write live session: %v", err)
	}

	err := restoreTrashedSessionFile(dir, trashPath)
	if err == nil || !strings.Contains(err.Error(), "session already exists") {
		t.Fatalf("restore conflict error = %v, want session already exists", err)
	}
	if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "live") {
		t.Fatalf("live session should remain, got %q err=%v", string(got), err)
	}
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("trash session should remain after rejected restore: %v", err)
	}
}

func TestRestoreTrashedSessionFileRestoresSubagents(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}
	ref := "sa_20260102_030405_000000000_aabbccddeeff"
	writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}

	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
		t.Fatalf("restore: %v", err)
	}

	if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); err != nil {
		t.Fatalf("subagent jsonl should be restored: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); err != nil {
		t.Fatalf("subagent meta should be restored: %v", err)
	}
}

func TestRestoreTrashedSessionFileRejectsSubagentConflict(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}
	ref := "sa_20260102_030405_000000000_aabbccddeeff"
	writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(dir, "subagents"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "subagents", ref+".jsonl"), []byte("conflict"), 0o644); err != nil {
		t.Fatal(err)
	}

	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	if err := restoreTrashedSessionFile(dir, trashPath); err == nil {
		t.Fatal("restore should fail on subagent conflict")
	}
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("trash item should remain after failed restore: %v", err)
	}
	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
		t.Fatalf("parent session should not be restored after conflict, stat err = %v", err)
	}
}

func TestPurgeTrashedSessionFile(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	os.WriteFile(sessionPath, []byte("data"), 0o644)
	goalPath := store.SessionGoalState(sessionPath)
	os.WriteFile(goalPath, []byte(`{"goal":"purge"}`), 0o644)
	telemetryPath := sessionTelemetryPath(sessionPath)
	os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
	jobsDir := jobs.ArtifactDir(sessionPath)
	if err := os.MkdirAll(jobsDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
		t.Fatal(err)
	}
	if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
		t.Fatal(err)
	}
	if err := recordSessionPlannerDisplay(dir, sessionPath, "prompt", []HistoryMessage{{
		Role: "tool", ToolName: "read_file", Content: "sensitive cancelled output",
	}}); err != nil {
		t.Fatal(err)
	}
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	if got := sessionPlannerDisplayTurns(dir, sessionPath); len(got) != 1 {
		t.Fatalf("planner display should remain available while session is in trash: %+v", got)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
		t.Fatalf("purge: %v", err)
	}
	if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
		t.Fatalf("trash item should be removed after purge, stat err = %v", err)
	}
	if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
		t.Fatalf("session jobs should be removed after purge, stat err = %v", err)
	}
	if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
		t.Fatalf("session goal state should be removed after purge, stat err = %v", err)
	}
	if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
		t.Fatalf("session telemetry should be removed after purge, stat err = %v", err)
	}
	if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
		t.Fatal("title should be removed after purge")
	}
	if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
		t.Fatalf("display sidecar should be removed after purge, got %q", got)
	}
	if got := sessionPlannerDisplayTurns(dir, sessionPath); len(got) != 0 {
		t.Fatalf("planner display sidecar should be removed after purge: %+v", got)
	}
}

func TestPurgeTrashedSessionFileRemovesSubagents(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	os.WriteFile(sessionPath, []byte("data"), 0o644)
	ref := "sa_20260102_030405_000000000_aabbccddeeff"
	writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
	if err := deleteSessionFile(dir, sessionPath); err != nil {
		t.Fatalf("trash: %v", err)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
	if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
		t.Fatalf("purge: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents", ref+".jsonl")); !os.IsNotExist(err) {
		t.Fatalf("trashed subagent should be removed by purge, stat err = %v", err)
	}
}

func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
	dir := t.TempDir()
	outside := filepath.Join(t.TempDir(), "outside.jsonl")
	os.WriteFile(outside, []byte("data"), 0o644)
	itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
	if err := os.MkdirAll(itemDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
		t.Skipf("symlink unavailable: %v", err)
	}

	got, err := listTrashedSessionFiles(dir)
	if err != nil {
		t.Fatalf("list trash: %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("trash listing should skip symlink escape, got %v", got)
	}
}

func TestDeleteSessionFileMissing(t *testing.T) {
	dir := t.TempDir()
	// Deleting a non-existent file should not error.
	if err := deleteSessionFile(dir, filepath.Join(dir, "missing.jsonl")); err != nil {
		t.Fatalf("delete missing: %v", err)
	}
}

func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "session.jsonl")
	for _, p := range []string{
		sessionPath,
		store.SessionMeta(sessionPath),
		store.SessionGoalState(sessionPath),
		store.SessionEventLog(sessionPath),
		store.SessionEventIndex(sessionPath),
		store.SessionConflictLog(sessionPath),
		store.SessionRecoveryState(sessionPath),
		sessionTelemetryPath(sessionPath),
		store.SessionLockFile(sessionPath),
		store.SessionLeaseLock(sessionPath),
		store.SessionLeaseInfo(sessionPath),
	} {
		if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
			t.Fatalf("write %s: %v", p, err)
		}
	}
	if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
		t.Fatalf("record display: %v", err)
	}
	ckptDir := store.SessionCheckpointDir(sessionPath)
	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	jobsDir := jobs.ArtifactDir(sessionPath)
	if err := os.MkdirAll(jobsDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(jobsDir, "job.log"), []byte("output"), 0o644); err != nil {
		t.Fatal(err)
	}

	if err := removeDesktopSessionArtifacts(sessionPath); err != nil {
		t.Fatalf("removeDesktopSessionArtifacts: %v", err)
	}

	for _, p := range []string{
		sessionPath,
		store.SessionMeta(sessionPath),
		store.SessionGoalState(sessionPath),
		store.SessionEventLog(sessionPath),
		store.SessionEventIndex(sessionPath),
		store.SessionConflictLog(sessionPath),
		store.SessionRecoveryState(sessionPath),
		sessionTelemetryPath(sessionPath),
		store.SessionLockFile(sessionPath),
		store.SessionLeaseLock(sessionPath),
		store.SessionLeaseInfo(sessionPath),
		ckptDir,
		jobsDir,
	} {
		if _, err := os.Stat(p); !os.IsNotExist(err) {
			t.Fatalf("%s should be removed, stat err = %v", p, err)
		}
	}
	if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
		t.Fatalf("session display key should be removed, got %q", got)
	}
}

func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
	dir := t.TempDir()
	outside := filepath.Join(t.TempDir(), "outside.jsonl")
	os.WriteFile(outside, []byte("data"), 0o644)

	if err := deleteSessionFile(dir, outside); err == nil {
		t.Fatal("delete outside dir should fail")
	}
	if _, err := os.Stat(outside); err != nil {
		t.Fatalf("outside file should remain: %v", err)
	}
}

func TestDeleteSessionFileRejectsNonJSONL(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.meta")
	os.WriteFile(path, []byte("data"), 0o644)

	if err := deleteSessionFile(dir, path); err == nil {
		t.Fatal("delete non-jsonl should fail")
	}
	if _, err := os.Stat(path); err != nil {
		t.Fatalf("non-jsonl file should remain: %v", err)
	}
}

func TestDeleteSessionFileRejectsSymlinkEscape(t *testing.T) {
	dir := t.TempDir()
	outside := filepath.Join(t.TempDir(), "outside.jsonl")
	os.WriteFile(outside, []byte("data"), 0o644)
	link := filepath.Join(dir, "link.jsonl")
	if err := os.Symlink(outside, link); err != nil {
		t.Skipf("symlink unavailable: %v", err)
	}

	if err := deleteSessionFile(dir, link); err == nil {
		t.Fatal("delete symlink escape should fail")
	}
	if _, err := os.Stat(outside); err != nil {
		t.Fatalf("outside target should remain: %v", err)
	}
}

func TestMovePathIfExistsCopyFallback(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "src.txt")
	if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
		t.Fatal(err)
	}
	dst := filepath.Join(dir, "dst.txt")

	// Test normal move.
	if err := movePathIfExists(src, dst); err != nil {
		t.Fatalf("move: %v", err)
	}
	if _, err := os.Stat(src); !os.IsNotExist(err) {
		t.Fatal("src should be removed")
	}
	if got, err := os.ReadFile(dst); err != nil || string(got) != "hello" {
		t.Fatalf("dst content = %q, err = %v", got, err)
	}

	// Test non-existent source is no-op.
	if err := movePathIfExists(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "other.txt")); err != nil {
		t.Fatalf("move missing: %v", err)
	}
}

func TestCopyFallbackTreatsMissingSourceAsAlreadyMoved(t *testing.T) {
	dir := t.TempDir()
	if err := copyAndRemove(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "dst.txt")); err != nil {
		t.Fatalf("copy missing source: %v", err)
	}

	dstDir := filepath.Join(dir, "dst-dir")
	if err := copyDir(filepath.Join(dir, "missing-dir"), dstDir, 0o755); err != nil {
		t.Fatalf("copy missing dir: %v", err)
	}
	if _, err := os.Stat(dstDir); !os.IsNotExist(err) {
		t.Fatalf("copy missing dir should not leave an empty target, stat err = %v", err)
	}
}

func TestCopyFallbackRemovesPartialTargetWhenSourceVanishesMidCopy(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "src.txt")
	dst := filepath.Join(dir, "dst.txt")
	if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
		t.Fatalf("write source: %v", err)
	}

	restore := copyPathFn
	copyPathFn = func(copySrc, copyDst string) error {
		// Simulate the source vanishing mid-copy: a truncated destination has
		// already been written when the copy fails.
		if err := os.WriteFile(copyDst, []byte("session tra"), 0o644); err != nil {
			t.Fatalf("write partial destination: %v", err)
		}
		if err := os.Remove(copySrc); err != nil {
			t.Fatalf("remove source: %v", err)
		}
		return errors.New("simulated read failure")
	}
	t.Cleanup(func() { copyPathFn = restore })

	if err := copyAndRemove(src, dst); err != nil {
		t.Fatalf("copyAndRemove should treat vanished source as already moved: %v", err)
	}
	if _, err := os.Stat(dst); !os.IsNotExist(err) {
		t.Fatalf("partial destination should be removed, stat err = %v", err)
	}
}

func TestCopyFallbackKeepsErrorWhenSourceStillExists(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "src.txt")
	dst := filepath.Join(dir, "dst.txt")
	if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
		t.Fatalf("write source: %v", err)
	}

	restore := copyPathFn
	simulated := errors.New("simulated copy failure")
	copyPathFn = func(copySrc, copyDst string) error {
		return simulated
	}
	t.Cleanup(func() { copyPathFn = restore })

	if err := copyAndRemove(src, dst); !errors.Is(err, simulated) {
		t.Fatalf("copyAndRemove error = %v, want simulated copy failure", err)
	}
	if _, err := os.Stat(src); err != nil {
		t.Fatalf("source should be untouched on real copy failure: %v", err)
	}
}

func TestCopyAndRemoveDirectory(t *testing.T) {
	dir := t.TempDir()
	srcDir := filepath.Join(dir, "src")
	if err := os.MkdirAll(filepath.Join(srcDir, "sub"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("file a"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("file b"), 0o644); err != nil {
		t.Fatal(err)
	}
	dstDir := filepath.Join(dir, "dst")

	if err := copyAndRemove(srcDir, dstDir); err != nil {
		t.Fatalf("copyAndRemove: %v", err)
	}
	if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
		t.Fatal("src dir should be removed")
	}
	if got, err := os.ReadFile(filepath.Join(dstDir, "a.txt")); err != nil || string(got) != "file a" {
		t.Fatalf("dst a.txt = %q, err = %v", got, err)
	}
	if got, err := os.ReadFile(filepath.Join(dstDir, "sub", "b.txt")); err != nil || string(got) != "file b" {
		t.Fatalf("dst sub/b.txt = %q, err = %v", got, err)
	}
}

func TestCopyAndRemoveDirectoryPreservesSymlinks(t *testing.T) {
	dir := t.TempDir()
	outside := filepath.Join(t.TempDir(), "outside.txt")
	if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
		t.Fatal(err)
	}
	srcDir := filepath.Join(dir, "src")
	if err := os.MkdirAll(srcDir, 0o755); err != nil {
		t.Fatal(err)
	}
	link := filepath.Join(srcDir, "link.txt")
	if err := os.Symlink(outside, link); err != nil {
		t.Skipf("symlink unavailable: %v", err)
	}
	dstDir := filepath.Join(dir, "dst")

	if err := copyAndRemove(srcDir, dstDir); err != nil {
		t.Fatalf("copyAndRemove: %v", err)
	}
	if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
		t.Fatal("src dir should be removed")
	}
	dstLink := filepath.Join(dstDir, "link.txt")
	info, err := os.Lstat(dstLink)
	if err != nil {
		t.Fatal(err)
	}
	if info.Mode()&os.ModeSymlink == 0 {
		t.Fatalf("dst link should remain a symlink, mode=%v", info.Mode())
	}
	target, err := os.Readlink(dstLink)
	if err != nil {
		t.Fatal(err)
	}
	if target != outside {
		t.Fatalf("dst link target = %q, want %q", target, outside)
	}
}

func writeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
	t.Helper()
	subagentDir := filepath.Join(dir, "subagents")
	if err := os.MkdirAll(subagentDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	meta := agent.SubagentMeta{
		Ref:           ref,
		Status:        agent.SubagentCompleted,
		Kind:          "task",
		Name:          "task",
		ParentSession: parentSession,
	}
	data, err := json.Marshal(meta)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
		t.Fatal(err)
	}
}

// sessionTitlesPath

func TestSessionTitlesPath(t *testing.T) {
	got := sessionTitlesPath("/sessions")
	want := filepath.Join("/sessions", ".titles.json")
	if got != want {
		t.Errorf("sessionTitlesPath = %q, want %q", got, want)
	}
}

func TestSessionDisplayRoundTrip(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "s.jsonl")
	content := "prefix\n--- Begin [Pasted text #1 · 5 lines] ---\nfull text\n--- End [Pasted text #1 · 5 lines] ---"
	display := "[Pasted text #1 · 5 lines]"
	if err := recordSessionDisplay(dir, sessionPath, content, display); err != nil {
		t.Fatalf("record display: %v", err)
	}
	if got := resolveSessionDisplay(dir, sessionPath, content); got != display {
		t.Fatalf("display = %q, want %q", got, display)
	}
	if got := resolveSessionDisplay(dir, sessionPath, "other"); got != "other" {
		t.Fatalf("unknown content should pass through, got %q", got)
	}
}

func TestRecordSessionPlannerDisplayConcurrentPreservesEverySession(t *testing.T) {
	dir := t.TempDir()
	const writers = 32
	start := make(chan struct{})
	errs := make(chan error, writers)
	var wg sync.WaitGroup
	for i := range writers {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			<-start
			path := filepath.Join(dir, fmt.Sprintf("session-%02d.jsonl", i))
			errs <- recordSessionPlannerDisplay(dir, path, fmt.Sprintf("prompt-%02d", i), []HistoryMessage{{
				Role: "assistant", Content: fmt.Sprintf("answer-%02d", i),
			}})
		}(i)
	}
	close(start)
	wg.Wait()
	close(errs)
	for err := range errs {
		if err != nil {
			t.Fatalf("record planner display: %v", err)
		}
	}

	got := loadSessionPlannerDisplays(dir)
	if len(got) != writers {
		t.Fatalf("planner display sessions = %d, want %d", len(got), writers)
	}
	for i := range writers {
		key := fmt.Sprintf("session-%02d.jsonl", i)
		if len(got[key]) != 1 || len(got[key][0].Messages) != 1 || got[key][0].Messages[0].Content != fmt.Sprintf("answer-%02d", i) {
			t.Fatalf("planner display %s = %+v", key, got[key])
		}
	}
}

func TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession(t *testing.T) {
	if role := os.Getenv("REASONIX_PLANNER_DISPLAY_HELPER"); role != "" {
		dir := os.Getenv("REASONIX_PLANNER_DISPLAY_DIR")
		sessionPlannerDisplayExternalLockTimeout = 5 * time.Second
		attempted := filepath.Join(dir, role+".attempted")
		loaded := filepath.Join(dir, role+".loaded")
		release := filepath.Join(dir, role+".release")
		if err := os.WriteFile(attempted, []byte("ready"), 0o600); err != nil {
			t.Fatal(err)
		}
		if role == "second" && !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.begin"), 10*time.Second) {
			t.Fatal("timed out waiting to begin second update")
		}
		sessionPlannerDisplayUpdateAfterLoad = func() {
			if err := os.WriteFile(loaded, []byte("loaded"), 0o600); err != nil {
				t.Fatal(err)
			}
			if !waitForPlannerDisplayTestFile(release, 10*time.Second) {
				t.Fatalf("timed out waiting for %s release", role)
			}
		}
		err := recordSessionPlannerDisplay(dir, filepath.Join(dir, role+".jsonl"), role+" prompt", []HistoryMessage{{
			Role: "assistant", Content: role + " answer",
		}})
		if err != nil {
			t.Fatal(err)
		}
		return
	}

	dir := t.TempDir()
	startHelper := func(role string, output *strings.Builder) *exec.Cmd {
		cmd := exec.Command(os.Args[0], "-test.run=^TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession$")
		cmd.Env = append(os.Environ(),
			"REASONIX_PLANNER_DISPLAY_HELPER="+role,
			"REASONIX_PLANNER_DISPLAY_DIR="+dir,
		)
		cmd.Stdout = output
		cmd.Stderr = output
		if err := cmd.Start(); err != nil {
			t.Fatalf("start %s helper: %v", role, err)
		}
		return cmd
	}
	releaseHelper := func(role string) {
		if err := os.WriteFile(filepath.Join(dir, role+".release"), []byte("release"), 0o600); err != nil {
			t.Fatalf("release %s helper: %v", role, err)
		}
	}

	var firstOutput, secondOutput strings.Builder
	first := startHelper("first", &firstOutput)
	if !waitForPlannerDisplayTestFile(filepath.Join(dir, "first.loaded"), 5*time.Second) {
		releaseHelper("first")
		_ = first.Wait()
		t.Fatalf("first helper did not load sidecar: %s", firstOutput.String())
	}
	second := startHelper("second", &secondOutput)
	if !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.attempted"), 5*time.Second) {
		releaseHelper("first")
		_ = os.WriteFile(filepath.Join(dir, "second.begin"), []byte("begin"), 0o600)
		releaseHelper("second")
		_ = first.Wait()
		_ = second.Wait()
		t.Fatalf("second helper did not attempt update: %s", secondOutput.String())
	}
	if err := os.WriteFile(filepath.Join(dir, "second.begin"), []byte("begin"), 0o600); err != nil {
		t.Fatal(err)
	}
	if waitForPlannerDisplayTestFile(filepath.Join(dir, "second.loaded"), time.Second) {
		releaseHelper("first")
		releaseHelper("second")
		_ = first.Wait()
		_ = second.Wait()
		t.Fatal("second process loaded the stale sidecar while the first update still held its transaction lock")
	}

	releaseHelper("first")
	if err := first.Wait(); err != nil {
		releaseHelper("second")
		_ = second.Wait()
		t.Fatalf("first helper failed: %v\n%s", err, firstOutput.String())
	}
	if !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.loaded"), 5*time.Second) {
		releaseHelper("second")
		_ = second.Wait()
		t.Fatalf("second helper did not enter transaction after release: %s", secondOutput.String())
	}
	releaseHelper("second")
	if err := second.Wait(); err != nil {
		t.Fatalf("second helper failed: %v\n%s", err, secondOutput.String())
	}

	got := loadSessionPlannerDisplays(dir)
	for _, role := range []string{"first", "second"} {
		turns := got[role+".jsonl"]
		if len(turns) != 1 || len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != role+" answer" {
			t.Fatalf("%s planner display lost after cross-process updates: %#v", role, got)
		}
	}
}

func TestRecordSessionPlannerDisplayDoesNotOverwriteCorruptSidecar(t *testing.T) {
	dir := t.TempDir()
	corrupt := []byte(`{"session.jsonl":[`)
	if err := os.WriteFile(sessionPlannerDisplayPath(dir), corrupt, 0o600); err != nil {
		t.Fatal(err)
	}
	err := recordSessionPlannerDisplay(dir, filepath.Join(dir, "new.jsonl"), "prompt", []HistoryMessage{{Role: "assistant", Content: "answer"}})
	if err == nil {
		t.Fatal("record should reject a corrupt sidecar instead of replacing it with an empty map")
	}
	got, readErr := os.ReadFile(sessionPlannerDisplayPath(dir))
	if readErr != nil {
		t.Fatal(readErr)
	}
	if string(got) != string(corrupt) {
		t.Fatalf("corrupt sidecar was overwritten: %q", got)
	}
}

func TestRemoveSessionPlannerDisplayRetiresCorruptSidecar(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(sessionPlannerDisplayPath(dir), []byte(`{"session.jsonl":[`), 0o600); err != nil {
		t.Fatal(err)
	}
	if err := removeSessionPlannerDisplay(dir, filepath.Join(dir, "session.jsonl")); err != nil {
		t.Fatalf("remove display from corrupt sidecar: %v", err)
	}
	if _, err := os.Stat(sessionPlannerDisplayPath(dir)); !os.IsNotExist(err) {
		t.Fatalf("corrupt sidecar should be retired during destructive cleanup, stat err = %v", err)
	}
}

func waitForPlannerDisplayTestFile(path string, timeout time.Duration) bool {
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		if _, err := os.Stat(path); err == nil {
			return true
		}
		time.Sleep(5 * time.Millisecond)
	}
	return false
}

func TestRemoveDesktopSessionArtifactsPrunesPlannerDisplay(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.jsonl")
	if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	if err := recordSessionPlannerDisplay(dir, path, "prompt", []HistoryMessage{{
		Role: "tool", ToolName: "read_file", Content: "sensitive cancelled output",
	}}); err != nil {
		t.Fatal(err)
	}
	if err := removeDesktopSessionArtifacts(path); err != nil {
		t.Fatal(err)
	}
	if got := sessionPlannerDisplayTurns(dir, path); len(got) != 0 {
		t.Fatalf("planner display retained after permanent session removal: %+v", got)
	}
	if _, err := os.Stat(sessionPlannerDisplayPath(dir)); !os.IsNotExist(err) {
		t.Fatalf("empty planner display sidecar should be removed, stat err = %v", err)
	}
}

func TestRecordSessionPlannerDisplayForTurnIsIdempotent(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.jsonl")
	first := []HistoryMessage{{Role: "assistant", Content: "partial"}}
	final := []HistoryMessage{{Role: "assistant", Content: "recovered partial"}, {Role: "notice", Content: "interrupted"}}
	if err := recordSessionPlannerDisplayForTurn(dir, path, "turn-1", "prompt", first); err != nil {
		t.Fatalf("record first projection: %v", err)
	}
	if err := recordSessionPlannerDisplayForTurn(dir, path, "turn-1", "prompt", final); err != nil {
		t.Fatalf("upsert recovered projection: %v", err)
	}

	got := sessionPlannerDisplayTurns(dir, path)
	if len(got) != 1 || got[0].TurnID != "turn-1" || len(got[0].Messages) != 2 || got[0].Messages[0].Content != "recovered partial" {
		t.Fatalf("turn-id upsert = %+v, want one updated projection", got)
	}
}

func TestPruneSessionPlannerDisplaysRemovesOnlyOrphans(t *testing.T) {
	dir := t.TempDir()
	turn := []plannerDisplayTurn{{UserHash: messageDisplayKey("prompt"), Messages: []HistoryMessage{{Role: "assistant", Content: "display"}}}}
	if err := saveSessionPlannerDisplays(dir, sessionPlannerDisplayMap{
		"live.jsonl":           turn,
		"trashed.jsonl":        turn,
		"protected.jsonl":      turn,
		"missing.jsonl":        turn,
		"sidecar.events.jsonl": turn,
	}); err != nil {
		t.Fatalf("save planner displays: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
		t.Fatalf("write live session: %v", err)
	}
	trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
	if err := os.MkdirAll(trashDir, 0o755); err != nil {
		t.Fatalf("mkdir trash: %v", err)
	}
	if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
		t.Fatalf("write trashed session: %v", err)
	}

	if err := pruneSessionPlannerDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
		t.Fatalf("prune planner displays: %v", err)
	}
	got := loadSessionPlannerDisplays(dir)
	for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
		if got[key] == nil {
			t.Fatalf("%s planner display should be retained; got %#v", key, got)
		}
	}
	for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
		if got[key] != nil {
			t.Fatalf("%s planner display should be pruned; got %#v", key, got)
		}
	}
}

func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
	dir := t.TempDir()
	content := "expanded prompt"
	if err := saveSessionDisplays(dir, sessionDisplayMap{
		"live.jsonl":      map[string]string{messageDisplayKey(content): "live display"},
		"trashed.jsonl":   map[string]string{messageDisplayKey(content): "trash display"},
		"protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
		"missing.jsonl":   map[string]string{messageDisplayKey(content): "missing display"},
		"sidecar.events.jsonl": map[string]string{
			messageDisplayKey(content): "invalid display",
		},
	}); err != nil {
		t.Fatalf("save displays: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
		t.Fatalf("write live session: %v", err)
	}
	trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
	if err := os.MkdirAll(trashDir, 0o755); err != nil {
		t.Fatalf("mkdir trash: %v", err)
	}
	if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
		t.Fatalf("write trashed session: %v", err)
	}

	if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
		t.Fatalf("prune displays: %v", err)
	}

	got := loadSessionDisplays(dir)
	for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
		if got[key] == nil {
			t.Fatalf("%s display should be retained; got %#v", key, got)
		}
	}
	for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
		if got[key] != nil {
			t.Fatalf("%s display should be pruned; got %#v", key, got)
		}
	}
}

func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
	dir := t.TempDir()
	sessionPath := filepath.Join(dir, "s.jsonl")
	if err := recordSessionDisplay(dir, sessionPath, "same", "same"); err != nil {
		t.Fatalf("record display: %v", err)
	}
	if _, err := os.Stat(sessionDisplayPath(dir)); !os.IsNotExist(err) {
		t.Fatalf("noop display should not create sidecar, stat err = %v", err)
	}
}

func TestRecordSessionDisplaySerializesConcurrentTabs(t *testing.T) {
	dir := t.TempDir()
	const tabs = 32
	errs := make(chan error, tabs)
	var wg sync.WaitGroup
	for i := range tabs {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			path := filepath.Join(dir, fmt.Sprintf("tab-%02d.jsonl", i))
			content := fmt.Sprintf("expanded-%02d", i)
			display := fmt.Sprintf("display-%02d", i)
			errs <- recordSessionDisplay(dir, path, content, display)
		}(i)
	}
	wg.Wait()
	close(errs)
	for err := range errs {
		if err != nil {
			t.Fatalf("recordSessionDisplay: %v", err)
		}
	}

	got := loadSessionDisplays(dir)
	for i := range tabs {
		key := fmt.Sprintf("tab-%02d.jsonl", i)
		content := fmt.Sprintf("expanded-%02d", i)
		if display := got[key][messageDisplayKey(content)]; display != fmt.Sprintf("display-%02d", i) {
			t.Fatalf("%s display = %q, want retained concurrent value", key, display)
		}
	}
}
