package main

import (
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

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

func carryingController(carried []provider.Message, path string) *control.Controller {
	sess := &agent.Session{}
	sess.Replace(carried)
	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
}

// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
// config change rebuilds the controller and carries the conversation forward. Each
// rebuild must keep writing to the same file, so a run of them leaves exactly one
// history entry — not a new identical duplicate per rebuild.
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
	dir := t.TempDir()
	path := agent.NewSessionPath(dir, "model-a")
	ctrl := controllerWithContent(t, path)
	if err := ctrl.Snapshot(); err != nil {
		t.Fatal(err)
	}

	for range 5 {
		prevPath := ctrl.SessionPath()
		carried := ctrl.History()
		ctrl.Close()

		newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
		ctrl = carryingController(carried, newPath)
		if err := ctrl.Snapshot(); err != nil {
			t.Fatal(err)
		}
	}
	ctrl.Close()

	infos, err := agent.ListSessions(dir)
	if err != nil {
		t.Fatal(err)
	}
	if len(infos) != 1 {
		paths := make([]string, len(infos))
		for i, s := range infos {
			paths[i] = filepath.Base(s.Path)
		}
		t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
	}
}

// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.

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

	app := NewApp()
	first, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatal(err)
	}
	if first.SessionPath == "" {
		t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
	}
	if _, err := os.Stat(first.SessionPath); err != nil {
		t.Fatalf("pre-created blank session should exist: %v", err)
	}
	second, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatal(err)
	}
	if second.ID != first.ID {
		t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
	}
	if tabs := app.ListTabs(); len(tabs) != 1 {
		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
	}
}

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

	globalRoot := globalWorkspaceRoot()
	if err := os.MkdirAll(globalRoot, 0o755); err != nil {
		t.Fatal(err)
	}
	sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
	if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	topic, err := app.CreateTopic("global", "", "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	app.tabs["blank"] = &WorkspaceTab{
		ID:            "blank",
		Scope:         "global",
		WorkspaceRoot: globalRoot,
		TopicID:       topic.ID,
		TopicTitle:    defaultTopicTitle,
		SessionPath:   sessionPath,
		disabledMCP:   map[string]ServerView{},
	}
	app.tabOrder = []string{"blank"}
	app.activeTabID = "blank"

	meta, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if meta.ID != "blank" {
		t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
	}
}

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

	app := NewApp()
	topic, err := app.CreateTopic("global", "", "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	globalRoot := globalWorkspaceRoot()
	dir := desktopSessionDir(globalRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	stubPath := filepath.Join(dir, "empty-stub.jsonl")
	if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
		t.Fatalf("write empty stub: %v", err)
	}
	now := time.Now()
	if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
		CreatedAt:     now.Add(-time.Minute),
		UpdatedAt:     now,
		Scope:         "global",
		WorkspaceRoot: globalRoot,
		TopicID:       topic.ID,
		TopicTitle:    defaultTopicTitle,
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}

	meta, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if meta.TopicID != topic.ID {
		t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
	}
}

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

	app := NewApp()
	before := time.Now().UnixMilli()
	meta, err := app.EnsureBlankTab("global", "")
	after := time.Now().UnixMilli()
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}

	createdAt := loadTopicCreatedAt("", meta.TopicID)
	if createdAt < before || createdAt > after {
		t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
	}

	nodes := app.ListProjectTree()
	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
		t.Fatalf("project tree = %#v, want Global with one topic", nodes)
	}
	if got := nodes[0].Children[0].CreatedAt; got != createdAt {
		t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
	}
}

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

	const topicID = "topic_20260704-104018_deadbeef"
	if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
		t.Fatalf("set topic title: %v", err)
	}
	if err := prependTopicInProjectsFile("", topicID, false); err != nil {
		t.Fatalf("prepend topic: %v", err)
	}
	if got := loadTopicCreatedAt("", topicID); got != 0 {
		t.Fatalf("createdAt before reuse = %d, want 0", got)
	}

	app := NewApp()
	meta, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if meta.TopicID != topicID {
		t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
	}

	expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
	if got := loadTopicCreatedAt("", topicID); got != expected {
		t.Fatalf("repaired createdAt = %d, want %d", got, expected)
	}
}

// EnsureBlankTab reuses an already-open project-scoped blank tab.

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

	projectRoot := t.TempDir()
	app := NewApp()
	first, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatal(err)
	}
	second, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatal(err)
	}
	if second.ID != first.ID {
		t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
	}
	if tabs := app.ListTabs(); len(tabs) != 1 {
		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
	}
}

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

	projectA := robustTempDir(t)
	projectB := robustTempDir(t)
	if err := addProject(projectA, "Project A"); err != nil {
		t.Fatalf("add project A: %v", err)
	}
	if err := addProject(projectB, "Project B"); err != nil {
		t.Fatalf("add project B: %v", err)
	}

	app := NewApp()
	first, err := app.EnsureBlankTab("project", projectA)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project A): %v", err)
	}
	tabA := waitForTabReady(t, app, first.ID)
	if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
		t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
	}

	second, err := app.EnsureBlankTab("project", projectB)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project B): %v", err)
	}
	if second.ID == first.ID {
		t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
	}
	tabB := waitForTabReady(t, app, second.ID)

	if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
		t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
	}
	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
		t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
	}
	if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
		t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
	}
	if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
		t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
	}
	sys := systemPromptFrom(tabB.Ctrl.History())
	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
		t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
	}
	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
		t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
	}
}

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

	projectA := robustTempDir(t)
	projectB := robustTempDir(t)
	pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
	if err != nil {
		t.Fatalf("create project A empty session: %v", err)
	}
	tab := &WorkspaceTab{
		ID:            "blank-project-b",
		Scope:         "project",
		WorkspaceRoot: projectB,
		SessionPath:   pathA,
	}

	if blankTabSessionPathHasNoContent(tab) {
		t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
	}
}

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

	projectA := robustTempDir(t)
	projectB := robustTempDir(t)
	if err := addProject(projectA, "Project A"); err != nil {
		t.Fatalf("add project A: %v", err)
	}
	if err := addProject(projectB, "Project B"); err != nil {
		t.Fatalf("add project B: %v", err)
	}

	app := NewApp()
	first, err := app.EnsureBlankTab("project", projectA)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project A): %v", err)
	}
	waitForTabReady(t, app, first.ID)

	second, err := app.EnsureBlankTab("project", projectB)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project B): %v", err)
	}
	tabB := waitForTabReady(t, app, second.ID)
	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")

	forked, err := app.Fork(turn)
	if err != nil {
		t.Fatalf("Fork: %v", err)
	}
	if forked.ID == "" || forked.ID == second.ID {
		t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
	}
	forkTab := waitForTabReady(t, app, forked.ID)
	if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
		t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
	}
	if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
		t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
	}
	sys := systemPromptFrom(forkTab.Ctrl.History())
	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
		t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
	}
	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
		t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
	}
}

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

	projectA := robustTempDir(t)
	projectB := robustTempDir(t)
	if err := addProject(projectA, "Project A"); err != nil {
		t.Fatalf("add project A: %v", err)
	}
	if err := addProject(projectB, "Project B"); err != nil {
		t.Fatalf("add project B: %v", err)
	}

	app := NewApp()
	first, err := app.EnsureBlankTab("project", projectA)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project A): %v", err)
	}
	waitForTabReady(t, app, first.ID)

	second, err := app.EnsureBlankTab("project", projectB)
	if err != nil {
		t.Fatalf("EnsureBlankTab(project B): %v", err)
	}
	tabB := waitForTabReady(t, app, second.ID)
	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")

	if err := app.Rewind(turn, "conversation"); err != nil {
		t.Fatalf("Rewind: %v", err)
	}
	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
		t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
	}
	sys := systemPromptFrom(tabB.Ctrl.History())
	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
		t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
	}
	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
		t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
	}
}

func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
	t.Helper()
	if tab == nil || tab.Ctrl == nil {
		t.Fatal("tab controller is required")
	}
	sys := systemPromptFrom(tab.Ctrl.History())
	if strings.TrimSpace(sys) == "" {
		t.Fatal("tab controller did not expose a system prompt")
	}
	sessionDir := tab.Ctrl.SessionDir()
	sessionPath := tab.Ctrl.SessionPath()
	workspaceRoot := tab.Ctrl.WorkspaceRoot()
	label := tab.Ctrl.Label()
	tab.Ctrl.Close()

	sess := agent.NewSession(sys)
	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{
		Runner:        ag,
		Executor:      ag,
		SessionDir:    sessionDir,
		SessionPath:   sessionPath,
		WorkspaceRoot: workspaceRoot,
		Label:         label,
		SystemPrompt:  sys,
		Sink:          event.Discard,
	})
	tab.Ctrl = ctrl
	app.bindControllerDisplayRecorder(ctrl)
	return ctrl
}

func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
	t.Helper()
	ctrl.SubmitUserTurn(input, input)
	waitNotRunning(t, ctrl)

	deadline := time.Now().Add(time.Second)
	for {
		checkpoints := ctrl.Checkpoints()
		if len(checkpoints) > 0 {
			return checkpoints[len(checkpoints)-1].Turn
		}
		if time.Now().After(deadline) {
			t.Fatal("controller did not record a checkpoint")
		}
		time.Sleep(10 * time.Millisecond)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
		t.Fatalf("set stale auto title: %v", err)
	}
	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	meta, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if got := meta.TopicTitle; got != defaultTopicTitle {
		t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
		t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
		t.Fatalf("title source = %q, want auto", got)
	}
}

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "Manual title")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID

	meta, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if got := meta.TopicTitle; got != "Manual title" {
		t.Fatalf("reused manual topic title = %q, want Manual title", got)
	}
	if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
		t.Fatalf("stored title = %q, want Manual title", got)
	}
	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
		t.Fatalf("title source = %q, want manual", got)
	}
}

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

	projectRoot := t.TempDir()
	seedLegacyTopicBridge(t, projectRoot)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("create topic: %v", err)
	}
	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
		t.Fatalf("set stale auto title: %v", err)
	}
	activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
	reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
	app.tabs[activeTab.ID] = activeTab
	app.tabs[reusableTab.ID] = reusableTab
	app.tabOrder = []string{activeTab.ID, reusableTab.ID}
	app.activeTabID = activeTab.ID

	titlePath := topicTitlesPath(projectRoot)
	if err := os.Remove(titlePath); err != nil {
		t.Fatalf("remove title file: %v", err)
	}
	if err := os.Mkdir(titlePath, 0o755); err != nil {
		t.Fatalf("replace title file with directory: %v", err)
	}

	if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
		t.Fatal("EnsureBlankTab succeeded, want title reset error")
	}
	if got := app.activeTabID; got != activeTab.ID {
		t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
	}
}

// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for global scope.

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

	app := NewApp()
	topic, err := app.CreateTopic("global", "", "")
	if err != nil {
		t.Fatal(err)
	}

	meta, err := app.EnsureBlankTab("global", "")
	if err != nil {
		t.Fatal(err)
	}
	if meta.TopicID != topic.ID {
		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
	}
	if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
		t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
	}
}

// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for project scope.

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

	projectRoot := t.TempDir()
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatal(err)
	}

	meta, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatal(err)
	}
	if meta.TopicID != topic.ID {
		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
	}
	var topics []string
	for _, project := range loadProjectsFile().Projects {
		if project.Root == projectRoot {
			topics = project.Topics
			break
		}
	}
	if len(topics) != 1 {
		t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
	}
}

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

	projectRoot := robustTempDir(t)
	app := NewApp()
	topic, err := app.CreateTopic("project", projectRoot, "")
	if err != nil {
		t.Fatalf("CreateTopic: %v", err)
	}
	dir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
		t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
	}

	meta, err := app.EnsureBlankTab("project", projectRoot)
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if meta.TopicID == topic.ID {
		t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
	}
	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
		t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
	}
}

// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
	isolateDesktopUserDirs(t)

	// Race product on disk: deleted topic whose default title lingered in the
	// global title map (title-only, absent from GlobalTopics, no sessions).
	tombstonedID := "topic_tombstone_blank"
	if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
		t.Fatalf("set lingering title: %v", err)
	}
	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
		f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
		return true, nil
	}); err != nil {
		t.Fatalf("seed tombstone: %v", err)
	}

	meta, err := NewApp().EnsureBlankTab("global", "")
	if err != nil {
		t.Fatalf("EnsureBlankTab: %v", err)
	}
	if meta.TopicID == tombstonedID {
		t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
	}
	f := loadProjectsFile()
	if !containsDesktopString(f.DeletedTopics, tombstonedID) {
		t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
	}
	if containsDesktopString(f.GlobalTopics, tombstonedID) {
		t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
	}
}

// NewSession skips the snapshot when the current tab has no real conversation content.

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

	dir := t.TempDir()
	path := agent.NewSessionPath(dir, "model-a")
	ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
	app := NewApp()
	app.setTestCtrl(ctrl, "model-a")

	if err := app.NewSession(); err != nil {
		t.Fatal(err)
	}
	if got := ctrl.SessionPath(); got != path {
		t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
	}
}

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

	projectRoot := t.TempDir()
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	oldTopicID := "topic_old"
	oldTopicTitle := "Old topic"
	oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
	sess := &agent.Session{}
	sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})

	app := NewApp()
	app.setTestCtrl(ctrl, "model-a")
	tab := app.tabs["test"]
	tab.Scope = "project"
	tab.WorkspaceRoot = projectRoot
	tab.TopicID = oldTopicID
	tab.TopicTitle = oldTopicTitle
	tab.SessionPath = oldPath
	app.projectTreeChangedHook = func() {}

	if err := app.NewSession(); err != nil {
		t.Fatalf("NewSession: %v", err)
	}
	if got := tab.TopicID; got == "" || got == oldTopicID {
		t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
	}
	if got := tab.TopicTitle; got != defaultTopicTitle {
		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
	}
	newPath := ctrl.SessionPath()
	if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
		t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
	}
	if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write new session: %v", err)
	}
	if !app.maybeAutoTitleTopic(tab) {
		t.Fatalf("new session should auto-title its fresh topic")
	}

	oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
	if err != nil || !ok {
		t.Fatalf("load old meta: ok=%v err=%v", ok, err)
	}
	if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
		t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
	}
	newMeta, ok, err := agent.LoadBranchMeta(newPath)
	if err != nil || !ok {
		t.Fatalf("load new meta: ok=%v err=%v", ok, err)
	}
	if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
		t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
	}
}

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

	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := agent.NewSessionPath(dir, "model-a")
	ctrl := controllerWithContent(t, path)
	app := NewApp()
	app.projectTreeChangedHook = func() {}
	app.setTestCtrl(ctrl, "model-a")
	tab := app.tabs["test"]
	tab.TopicID = "topic_old"
	tab.TopicTitle = "Old topic"

	// Block desktopConfigDir-backed topic-index writes without affecting the
	// session directory, which exercises the post-NewSession repair failure path.
	if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
		t.Fatalf("mkdir desktop config parent: %v", err)
	}
	if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
		t.Fatalf("block desktop config dir: %v", err)
	}

	if err := app.NewSession(); err != nil {
		t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
	}
	if got := tab.TopicID; got == "" || got == "topic_old" {
		t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
	}
	if got := tab.TopicTitle; got != defaultTopicTitle {
		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
	}
	if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
		t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
	}
}
