package main

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/control"
	"reasonix/internal/event"
	"reasonix/internal/provider"
	"reasonix/internal/tool"
)

type stubProvider struct{}

const autosaveTestTimeout = 10 * time.Second

func (stubProvider) Name() string { return "stub" }

func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
	ch := make(chan provider.Chunk, 1)
	close(ch)
	return ch, nil
}

func controllerWithContent(t *testing.T, path string) *control.Controller {
	t.Helper()
	sess := agent.NewSession("system")
	sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
	sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
}

func waitForFile(t *testing.T, path, want string) {
	t.Helper()
	deadline := time.Now().Add(autosaveTestTimeout)
	for time.Now().Before(deadline) {
		if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
			return
		}
		time.Sleep(5 * time.Millisecond)
	}
	t.Fatalf("session file %q never contained %q", path, want)
}

func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
	t.Helper()
	waitForAutosaveIdleWithin(t, tab, autosaveTestTimeout)
}

func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		tab.saveMu.Lock()
		idle := !tab.saving && !tab.saveAgain
		tab.saveMu.Unlock()
		if idle {
			return
		}
		time.Sleep(5 * time.Millisecond)
	}
	t.Fatal("autosave loop did not become idle")
}

func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
	t.Helper()
	ctrl := controllerWithContent(t, path)
	tab := &WorkspaceTab{
		ID:            "test_tab",
		Ctrl:          ctrl,
		Scope:         "global",
		WorkspaceRoot: "",
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
	a := &App{
		tabs:        map[string]*WorkspaceTab{"test_tab": tab},
		activeTabID: "test_tab",
	}
	tab.sink.app = a
	return a, tab
}

// TestTurnDonePersistsSession proves a completed turn is written to disk without
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
	path := filepath.Join(t.TempDir(), "session.jsonl")
	_, tab := appWithTab(t, path)

	tab.sink.Emit(event.Event{Kind: event.TurnDone})

	waitForFile(t, path, "remember this turn")
	waitForAutosaveIdle(t, tab)
}

// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
// per-token event storm doesn't thrash the disk.
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
	path := filepath.Join(t.TempDir(), "session.jsonl")
	a, tab := appWithTab(t, path)
	_ = a

	tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})

	time.Sleep(50 * time.Millisecond)
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
	}
}

// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
// single-flight loop neither panics nor drops the final write.
func TestScheduleSnapshotCoalesces(t *testing.T) {
	path := filepath.Join(t.TempDir(), "session.jsonl")
	a, tab := appWithTab(t, path)
	_ = a

	var wg sync.WaitGroup
	for range 64 {
		wg.Go(func() {
			tab.sink.Emit(event.Event{Kind: event.TurnDone})
		})
	}
	wg.Wait()

	waitForFile(t, path, "acknowledged")
	waitForAutosaveIdle(t, tab)
}

func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
	path := filepath.Join(t.TempDir(), "blocked.jsonl")
	if err := os.Mkdir(path, 0o755); err != nil {
		t.Fatalf("mkdir blocked path: %v", err)
	}
	a, tab := appWithTab(t, path)
	_ = a

	tab.sink.Emit(event.Event{Kind: event.TurnDone})
	waitForAutosaveIdleWithin(t, tab, 5*time.Second)

	tab.saveMu.Lock()
	failures := tab.saveFailures
	tab.saveMu.Unlock()
	if failures == 0 {
		t.Fatal("autosave failure should be recorded and retried")
	}
	if info, err := os.Stat(path); err != nil || !info.IsDir() {
		t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
	}

	if err := os.Remove(path); err != nil {
		t.Fatalf("remove blocked dir: %v", err)
	}
	tab.sink.Emit(event.Event{Kind: event.TurnDone})
	waitForFile(t, path, "remember this turn")
	waitForAutosaveIdle(t, tab)

	tab.saveMu.Lock()
	failures = tab.saveFailures
	tab.saveMu.Unlock()
	if failures != 0 {
		t.Fatalf("autosave failures after recovery = %d, want 0", failures)
	}
}

func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
	isolateDesktopUserDirs(t)

	root := globalTabWorkspaceRoot()
	dir := desktopSessionDir(root)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	originalPath := filepath.Join(dir, "session.jsonl")
	originalTopic := "topic_original"
	if err := setTopicTitle("", originalTopic, "Original"); err != nil {
		t.Fatalf("set original topic title: %v", err)
	}
	current := agent.NewSession("sys")
	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
	if err := current.Save(originalPath); err != nil {
		t.Fatalf("Save current: %v", err)
	}
	if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
		Scope:         "global",
		TopicID:       originalTopic,
		TopicTitle:    "Original",
		Preview:       "first",
		Turns:         2,
		SchemaVersion: agent.BranchMetaCountsVersion,
	}); err != nil {
		t.Fatalf("SaveBranchMeta original: %v", err)
	}

	staleSess := agent.NewSession("sys")
	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
	app := &App{
		tabs:             map[string]*WorkspaceTab{},
		detachedSessions: map[string]*WorkspaceTab{},
		activeTabID:      "recovery_tab",
	}
	tab := &WorkspaceTab{
		ID:            "recovery_tab",
		Scope:         "global",
		WorkspaceRoot: root,
		TopicID:       originalTopic,
		TopicTitle:    "Original",
		SessionPath:   originalPath,
		Ready:         true,
		model:         "test-model",
		disabledMCP:   map[string]ServerView{},
	}
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	tab.Ctrl = control.New(control.Options{
		Executor:            staleExec,
		SessionDir:          dir,
		SessionPath:         originalPath,
		Label:               "test",
		Sink:                tab.sink,
		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
	})
	app.tabs[tab.ID] = tab

	if err := tab.Ctrl.Snapshot(); err != nil {
		t.Fatalf("Snapshot: %v", err)
	}
	recoveryPath := tab.Ctrl.SessionPath()
	if recoveryPath == "" || recoveryPath == originalPath {
		t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
	}
	if tab.SessionPath != recoveryPath {
		t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
	}
	saved := loadTabsFile()
	if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
		t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
	}
	if got := saved.Tabs[0].SessionPath; got != recoveryPath {
		t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
	}
	if tab.TopicID != originalTopic {
		t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
	}
	meta, ok, err := agent.LoadBranchMeta(recoveryPath)
	if err != nil || !ok {
		t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
	}
	if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
		t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
	}
	tabMeta := app.tabMeta(tab, true)
	if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
		t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
	}
	nodes := app.ListProjectTree()
	foundOriginal := false
	var walk func([]ProjectNode)
	walk = func(list []ProjectNode) {
		for _, node := range list {
			if node.Recovered {
				t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
			}
			if node.TopicID == originalTopic {
				foundOriginal = true
			}
			walk(node.Children)
		}
	}
	walk(nodes)
	if !foundOriginal {
		t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
	}
}

func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
	isolateDesktopUserDirs(t)

	root := globalTabWorkspaceRoot()
	dir := desktopSessionDir(root)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	originalPath := filepath.Join(dir, "session.jsonl")
	current := agent.NewSession("sys")
	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
	if err := current.Save(originalPath); err != nil {
		t.Fatalf("Save current: %v", err)
	}

	staleSess := agent.NewSession("sys")
	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
	recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
		OriginalPath: originalPath,
		BranchMeta: agent.BranchMeta{
			Name:       agent.RecoveryBranchDefaultName,
			Scope:      "global",
			TopicID:    "topic_recovery",
			TopicTitle: "Recovery",
		},
	})
	if err != nil {
		t.Fatalf("SaveRecoveryBranch: %v", err)
	}
	lease, err := agent.TryAcquireSessionLease(recovery.Path)
	if err != nil {
		t.Fatalf("TryAcquireSessionLease recovery: %v", err)
	}
	defer lease.Release()

	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
	runtimeEvents := make(chan runtimeEventEnvelope, 4)
	app := &App{
		ctx:              context.Background(),
		tabs:             map[string]*WorkspaceTab{},
		detachedSessions: map[string]*WorkspaceTab{},
		activeTabID:      "recovery_tab",
	}
	app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
		runtimeEvents <- runtimeEventEnvelope{
			ctx:     ctx,
			name:    name,
			payload: append([]any(nil), payload...),
		}
	}
	tab := &WorkspaceTab{
		ID:            "recovery_tab",
		Scope:         "global",
		WorkspaceRoot: root,
		TopicID:       "topic_original",
		TopicTitle:    "Original",
		SessionPath:   originalPath,
		Ready:         true,
		model:         "test-model",
		disabledMCP:   map[string]ServerView{},
	}
	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
	tab.Ctrl = control.New(control.Options{
		Executor:            staleExec,
		SessionDir:          dir,
		SessionPath:         originalPath,
		Label:               "test",
		Sink:                tab.sink,
		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
	})
	app.tabs[tab.ID] = tab
	app.mu.Lock()
	app.saveTabsLocked()
	app.mu.Unlock()

	err = tab.Ctrl.Snapshot()
	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
		t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
	}
	if got := tab.Ctrl.SessionPath(); got != originalPath {
		t.Fatalf("controller session path = %q, want original %q", got, originalPath)
	}
	if tab.SessionPath != originalPath {
		t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
	}
	if tab.TopicID != "topic_original" {
		t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
	}
	saved := loadTabsFile()
	if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
		t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
	}

	deadline := time.After(time.Second)
	for {
		select {
		case emitted := <-runtimeEvents:
			if emitted.name != "session:recovery-failed" {
				continue
			}
			if len(emitted.payload) != 1 {
				t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
			}
			failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
			if !ok {
				t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
			}
			if failed.Reason != "lease_held" {
				t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
			}
			return
		case <-deadline:
			t.Fatal("session:recovery-failed event was not emitted")
		}
	}
}

func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
	path := filepath.Join(t.TempDir(), "blocked.jsonl")
	if err := os.Mkdir(path, 0o755); err != nil {
		t.Fatalf("mkdir blocked path: %v", err)
	}
	a, _ := appWithTab(t, path)
	a.tabs["target_tab"] = &WorkspaceTab{
		ID:          "target_tab",
		Scope:       "global",
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}
	a.tabOrder = []string{"test_tab", "target_tab"}

	err := a.SetActiveTab("target_tab")
	if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
		t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
	}
	if a.activeTabID != "test_tab" {
		t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
	}
}

func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
	path := filepath.Join(t.TempDir(), "blocked.jsonl")
	if err := os.Mkdir(path, 0o755); err != nil {
		t.Fatalf("mkdir blocked path: %v", err)
	}
	a, tab := appWithTab(t, path)
	target := filepath.Join(t.TempDir(), "target.jsonl")
	sess := agent.NewSession("system")
	sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
	if err := sess.Save(target); err != nil {
		t.Fatalf("save target: %v", err)
	}
	loaded, err := agent.LoadSession(target)
	if err != nil {
		t.Fatalf("load target: %v", err)
	}

	err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
	if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
		t.Fatalf("rebind error = %v, want persistence failure", err)
	}
	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
		t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
	}
}

// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
// captured session path, so its Snapshot() call landed after DeleteSession
// trashed the file, "resurrecting" it.
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
	path := filepath.Join(t.TempDir(), "session.jsonl")

	doomed, doomedTab := appWithTab(t, path)
	// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
	survivor := &WorkspaceTab{
		ID:          "survivor_tab",
		Scope:       "global",
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}
	survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
	doomed.tabs["survivor_tab"] = survivor
	doomed.activeTabID = "test_tab"

	// Write the session file once via the autosave loop, then wait for idle so
	// the next TurnDone reliably kicks off a fresh loop.
	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
	waitForFile(t, path, "acknowledged")
	waitForAutosaveIdle(t, doomedTab)

	// Kick the autosave loop and close the tab in close succession. The loop
	// will be in flight when CloseTab runs — exactly the #4384 window.
	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
	if err := doomed.CloseTab("test_tab"); err != nil {
		t.Fatalf("CloseTab: %v", err)
	}

	// CloseTab must have returned only after the autosave loop finished. Remove
	// the file the way DeleteSession would (move to trash is just a remove here
	// since we only care that nothing rewrites the original path).
	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
		t.Fatalf("remove session file: %v", err)
	}

	// Give any would-be resurrection a chance to strike. If the autosave loop
	// were still alive (the bug), the file reappears here.
	time.Sleep(100 * time.Millisecond)
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
	}

	// And the controller's session path must be cleared so no future Snapshot
	// can write either.
	if got := doomedTab.Ctrl.SessionPath(); got != "" {
		t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
	}
}

func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
	path := filepath.Join(t.TempDir(), "blocked.jsonl")
	if err := os.Mkdir(path, 0o755); err != nil {
		t.Fatalf("mkdir blocked path: %v", err)
	}
	a, tab := appWithTab(t, path)
	survivor := &WorkspaceTab{
		ID:          "survivor_tab",
		Scope:       "global",
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}
	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
	a.tabs[survivor.ID] = survivor
	a.tabOrder = []string{tab.ID, survivor.ID}

	err := a.CloseTab(tab.ID)
	if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
		t.Fatalf("CloseTab error = %v, want persistence failure", err)
	}
	if _, ok := a.tabs[tab.ID]; !ok {
		t.Fatal("tab was removed even though its session could not be saved")
	}
	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
		t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
	}
}

// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
	doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
	survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")

	a, _ := appWithTab(t, doomedPath)
	survivorCtrl := controllerWithContent(t, survivorPath)
	survivor := &WorkspaceTab{
		ID:          "survivor_tab",
		Ctrl:        survivorCtrl,
		Scope:       "global",
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}
	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
	a.tabs["survivor_tab"] = survivor
	a.activeTabID = "test_tab"

	survivor.sink.Emit(event.Event{Kind: event.TurnDone})
	waitForFile(t, survivorPath, "acknowledged")
	waitForAutosaveIdle(t, survivor)

	if err := a.CloseTab("test_tab"); err != nil {
		t.Fatalf("CloseTab: %v", err)
	}

	if got := survivor.Ctrl.SessionPath(); got != survivorPath {
		t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
	}
	if survivor.closing {
		t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
	}
}

func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
	isolateDesktopUserDirs(t)

	dir := t.TempDir()
	path := filepath.Join(dir, "delete-open.jsonl")
	ctrl := controllerWithContent(t, path)
	tab := &WorkspaceTab{
		ID:          "delete_open",
		Scope:       "global",
		Ready:       true,
		Ctrl:        ctrl,
		disabledMCP: map[string]ServerView{},
	}
	app := &App{
		tabs:        map[string]*WorkspaceTab{"delete_open": tab},
		activeTabID: "delete_open",
	}
	if err := ctrl.Snapshot(); err != nil {
		t.Fatalf("snapshot: %v", err)
	}

	if err := app.DeleteSession(path); err != nil {
		t.Fatalf("DeleteSession: %v", err)
	}

	if got := ctrl.SessionPath(); got != "" {
		t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("session should be in trash: %v", err)
	}
}

func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
	isolateDesktopUserDirs(t)

	projectRoot := t.TempDir()
	topicID := "topic_clear_removed_runtime"
	if err := addProject(projectRoot, ""); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	dir := t.TempDir()
	path := filepath.Join(dir, "trash-open-topic.jsonl")
	ctrl := controllerWithContent(t, path)
	if err := ctrl.Snapshot(); err != nil {
		t.Fatalf("snapshot: %v", err)
	}
	if err := agent.SaveBranchMeta(path, agent.BranchMeta{
		CreatedAt:     time.Now().Add(-time.Minute),
		UpdatedAt:     time.Now(),
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Clear removed runtime",
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}
	tab := &WorkspaceTab{
		ID:            "trash_open",
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		TopicID:       topicID,
		TopicTitle:    "Clear removed runtime",
		Ready:         true,
		Ctrl:          ctrl,
		disabledMCP:   map[string]ServerView{},
	}
	survivor := &WorkspaceTab{
		ID:          "survivor",
		Scope:       "global",
		Ready:       true,
		disabledMCP: map[string]ServerView{},
	}
	app := &App{
		tabs:        map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
		tabOrder:    []string{"trash_open", "survivor"},
		activeTabID: "trash_open",
	}

	if err := app.TrashTopic(topicID); err != nil {
		t.Fatalf("TrashTopic: %v", err)
	}

	if got := ctrl.SessionPath(); got != "" {
		t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
	}
	trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
	if _, err := os.Stat(trashPath); err != nil {
		t.Fatalf("topic session should be in trash: %v", err)
	}
}
