package main

import (
	"encoding/json"
	"mime"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"golang.org/x/text/encoding/simplifiedchinese"
	"reasonix/internal/agent"
	"reasonix/internal/checkpoint"
	"reasonix/internal/config"
	"reasonix/internal/control"
)

// workspaceStatePath

func TestWorkspaceStatePath(t *testing.T) {
	// workspaceStatePath depends on config.MemoryUserDir() which needs a
	// config dir. We just verify it returns a consistent path.
	p1 := workspaceStatePath()
	p2 := workspaceStatePath()
	if p1 != p2 {
		t.Errorf("workspaceStatePath not stable: %q vs %q", p1, p2)
	}
	if p1 != "" && filepath.Base(p1) != "desktop-workspace" {
		t.Errorf("workspaceStatePath should end with desktop-workspace, got %q", p1)
	}
}

// saveWorkspace / loadWorkspace round-trip

func TestSaveLoadWorkspaceRoundTrip(t *testing.T) {
	// workspaceStatePath() resolves via os.UserConfigDir() (HOME on unix,
	// %AppData% on Windows); isolate both so the round-trip exercises real
	// persistence instead of no-opping or leaking into the dev config dir.
	isolateDesktopUserDirs(t)
	if workspaceStatePath() == "" {
		t.Fatal("workspaceStatePath() is empty after isolating the user config dir")
	}

	dir := t.TempDir()
	saveWorkspace(dir)
	if got := loadWorkspace(); got != dir {
		t.Errorf("loadWorkspace = %q, want %q", got, dir)
	}
}

func TestSaveWorkspaceOnlyRemembersLastWorkspace(t *testing.T) {
	isolateDesktopUserDirs(t)
	first := t.TempDir()
	second := t.TempDir()

	saveWorkspace(first)
	saveWorkspace(second)
	saveWorkspace(first)

	if got := loadWorkspace(); got != first {
		t.Fatalf("loadWorkspace = %q, want %q", got, first)
	}
	if got := loadWorkspaces(); len(got) != 0 {
		t.Fatalf("saveWorkspace should not maintain legacy workspace list, got %v", got)
	}
}

func TestDesktopMCPMigrationRootsIncludesLegacyWorkspaces(t *testing.T) {
	isolateDesktopUserDirs(t)
	active := t.TempDir()
	legacy := t.TempDir()
	tabRoot := t.TempDir()
	projectRoot := t.TempDir()

	saveWorkspace(active)
	if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
		t.Fatal(err)
	}
	b, err := json.Marshal([]string{legacy, active, legacy})
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
		t.Fatal(err)
	}
	if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: projectRoot}}}); err != nil {
		t.Fatal(err)
	}

	roots := desktopMCPMigrationRoots(desktopTabsFile{
		Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: tabRoot}},
	})
	want := []string{
		normalizeProjectRoot(active),
		normalizeProjectRoot(legacy),
		normalizeProjectRoot(tabRoot),
		normalizeProjectRoot(projectRoot),
	}
	if len(roots) != len(want) {
		t.Fatalf("roots len = %d, want %d: %+v", len(roots), len(want), roots)
	}
	for i, root := range want {
		if roots[i] != root {
			t.Fatalf("roots[%d] = %q, want %q; roots=%+v", i, roots[i], root, roots)
		}
	}
}

func TestRecoverLegacyProjectSidebarRootsPreservesUpgradeProjects(t *testing.T) {
	isolateDesktopUserDirs(t)
	existing := t.TempDir()
	active := t.TempDir()
	legacy := t.TempDir()
	tabRoot := t.TempDir()
	missing := filepath.Join(t.TempDir(), "missing")

	if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: existing, Title: "Existing"}}}); err != nil {
		t.Fatal(err)
	}
	saveWorkspace(active)
	if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
		t.Fatal(err)
	}
	b, err := json.Marshal([]string{legacy, active, missing, legacy})
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
		t.Fatal(err)
	}

	tabs := desktopTabsFile{
		Tabs: []desktopTabEntry{
			{Scope: "project", WorkspaceRoot: tabRoot},
			{Scope: "project", WorkspaceRoot: missing},
			{Scope: "global"},
		},
	}
	changed, err := recoverLegacyProjectSidebarRoots(tabs)
	if err != nil {
		t.Fatal(err)
	}
	if !changed {
		t.Fatal("recoverLegacyProjectSidebarRoots should add missing legacy projects")
	}

	projects := loadProjectsFile().Projects
	want := []string{
		normalizeProjectRoot(existing),
		normalizeProjectRoot(active),
		normalizeProjectRoot(legacy),
		normalizeProjectRoot(tabRoot),
	}
	if len(projects) != len(want) {
		t.Fatalf("project count = %d, want %d: %+v", len(projects), len(want), projects)
	}
	for i, root := range want {
		if projects[i].Root != root {
			t.Fatalf("projects[%d].Root = %q, want %q; projects=%+v", i, projects[i].Root, root, projects)
		}
	}
	if _, err := os.Stat(filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)); err != nil {
		t.Fatalf("recovery marker was not written: %v", err)
	}

	if err := removeProject(legacy); err != nil {
		t.Fatal(err)
	}
	changed, err = recoverLegacyProjectSidebarRoots(tabs)
	if err != nil {
		t.Fatal(err)
	}
	if changed {
		t.Fatal("recovery should be one-shot after the marker is written")
	}
	for _, project := range loadProjectsFile().Projects {
		if project.Root == normalizeProjectRoot(legacy) {
			t.Fatalf("removed legacy project was restored after marker: %+v", loadProjectsFile().Projects)
		}
		if project.Root == normalizeProjectRoot(missing) {
			t.Fatalf("missing legacy project should not be restored: %+v", loadProjectsFile().Projects)
		}
	}
}

func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
	isolateDesktopUserDirs(t)
	active := t.TempDir()
	added := t.TempDir()

	if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: active, Title: "Active"}}}); err != nil {
		t.Fatal(err)
	}

	entered := make(chan struct{})
	release := make(chan struct{})
	updateErr := make(chan error, 1)
	go func() {
		updateErr <- updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
			close(entered)
			<-release
			for i, project := range f.Projects {
				if project.Root == normalizeProjectRoot(active) {
					f.Projects[i].Title = "Active edited"
					return true, nil
				}
			}
			return false, nil
		})
	}()
	<-entered

	addErr := make(chan error, 1)
	go func() {
		addErr <- addProject(added, "Added")
	}()
	select {
	case err := <-addErr:
		t.Fatalf("addProject completed while another project update was in progress: %v", err)
	case <-time.After(10 * time.Millisecond):
	}
	close(release)
	if err := <-updateErr; err != nil {
		t.Fatal(err)
	}
	if err := <-addErr; err != nil {
		t.Fatal(err)
	}

	projects := loadProjectsFile().Projects
	if len(projects) != 2 {
		t.Fatalf("project count = %d, want 2: %+v", len(projects), projects)
	}
	if projects[0].Root != normalizeProjectRoot(active) || projects[0].Title != "Active edited" {
		t.Fatalf("active project was not preserved with edited title: %+v", projects)
	}
	if projects[1].Root != normalizeProjectRoot(added) || projects[1].Title != "Added" {
		t.Fatalf("concurrent project add was lost: %+v", projects)
	}

	if err := addProject(active, ""); err != nil {
		t.Fatal(err)
	}
	projects = loadProjectsFile().Projects
	if len(projects) != 2 || projects[1].Root != normalizeProjectRoot(added) {
		t.Fatalf("no-op addProject overwrote the added project: %+v", projects)
	}
}

func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()
	// A textually different spelling of the same folder. filepath.Join would
	// clean the dot segment away and hand back the identical string, so build
	// the spelling by hand.
	equivalentRoot := projectRoot + string(filepath.Separator) + "."

	f := normalizeProjectsFile(desktopProjectFile{
		Projects: []desktopProject{
			{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
			{Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
		},
		PinnedProjects: []string{equivalentRoot},
		SidebarOrder:   []string{equivalentRoot, projectRoot},
	})

	if len(f.Projects) != 1 {
		t.Fatalf("projects = %+v, want one merged project", f.Projects)
	}
	if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
		t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
	}
	if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
		t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
	}
	if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
		t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
	}
	if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
		t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
	}
	if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
		t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
	}
}

func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()

	if err := addProject(projectRoot, "Project"); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := removeProject(projectRoot); err != nil {
		t.Fatalf("remove project: %v", err)
	}
	if got := loadProjectsFile().Projects; len(got) != 0 {
		t.Fatalf("projects after remove = %+v, want none", got)
	}

	app := NewApp()
	installNoopRuntimeEvents(app)
	if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
		t.Fatalf("switch workspace: %v", err)
	} else if got != normalizeProjectRoot(projectRoot) {
		t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
	}

	projects := loadProjectsFile().Projects
	if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
		t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
	}
	if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
		t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
	}
}

// flipPathASCIICase returns the path with the case of every ASCII letter
// swapped — on Windows an equivalent spelling of the same folder that
// normalizeProjectRoot cannot fold away.
func flipPathASCIICase(t *testing.T, path string) string {
	t.Helper()
	flipped := strings.Map(func(r rune) rune {
		switch {
		case r >= 'a' && r <= 'z':
			return r - 'a' + 'A'
		case r >= 'A' && r <= 'Z':
			return r - 'A' + 'a'
		}
		return r
	}, path)
	if flipped == path {
		t.Skipf("path %q contains no ASCII letters to flip", path)
	}
	return flipped
}

func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("case-insensitive root matching only applies to Windows paths")
	}
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()
	flipped := flipPathASCIICase(t, projectRoot)

	f := normalizeProjectsFile(desktopProjectFile{
		Projects: []desktopProject{
			{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
			{Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
		},
		PinnedProjects: []string{flipped},
		SidebarOrder:   []string{flipped, projectRoot},
	})

	if len(f.Projects) != 1 {
		t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
	}
	canonical := f.Projects[0].Root
	if canonical != normalizeProjectRoot(projectRoot) {
		t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
	}
	if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
		t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
	}
	if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
		t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
	}
	if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
		t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
	}
	if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
		t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
	}
}

func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("case-insensitive root matching only applies to Windows paths")
	}
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()
	flipped := flipPathASCIICase(t, projectRoot)

	if err := addProject(projectRoot, "Project"); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := addProject(flipped, ""); err != nil {
		t.Fatalf("re-add project under flipped case: %v", err)
	}
	projects := loadProjectsFile().Projects
	if len(projects) != 1 {
		t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
	}
	if projects[0].Root != normalizeProjectRoot(flipped) {
		t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
	}
	if projects[0].Title != "Project" {
		t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
	}

	if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
		t.Fatalf("prepend topic: %v", err)
	}
	projects = loadProjectsFile().Projects
	if len(projects) != 1 {
		t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
	}
	if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
		t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
	}

	if err := removeProject(projectRoot); err != nil {
		t.Fatalf("remove project via original spelling: %v", err)
	}
	if got := loadProjectsFile().Projects; len(got) != 0 {
		t.Fatalf("projects after remove = %+v, want none", got)
	}
}

func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("case-insensitive root matching only applies to Windows paths")
	}
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()
	flipped := flipPathASCIICase(t, projectRoot)

	if err := addProject(projectRoot, "Project"); err != nil {
		t.Fatalf("add project: %v", err)
	}

	app := NewApp()
	installNoopRuntimeEvents(app)
	app.tabs["tab_case"] = &WorkspaceTab{
		ID:            "tab_case",
		Scope:         "project",
		WorkspaceRoot: normalizeProjectRoot(projectRoot),
		Ready:         true,
		disabledMCP:   map[string]ServerView{},
	}
	app.tabOrder = []string{"tab_case"}

	// Re-registering under the flipped spelling self-heals the registry root;
	// open tabs must follow so the frontend keeps comparing one string form.
	app.registerProjectRoot(flipped)

	projects := loadProjectsFile().Projects
	if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
		t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
	}
	if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
		t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
	}
}

func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("case-insensitive root matching only applies to Windows paths")
	}
	isolateDesktopUserDirs(t)
	projectRoot := t.TempDir()
	flipped := flipPathASCIICase(t, projectRoot)

	// Register under original spelling.
	if err := addProject(projectRoot, "Project"); err != nil {
		t.Fatalf("add project: %v", err)
	}
	if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
		t.Fatalf("prepend topic: %v", err)
	}

	// Write a session file with the original root spelling in its meta.
	sessionDir := desktopSessionDir(projectRoot)
	if err := os.MkdirAll(sessionDir, 0o755); err != nil {
		t.Fatalf("mkdir session dir: %v", err)
	}
	sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write session file: %v", err)
	}
	if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
		TopicID:       "topic_case",
		Scope:         "project",
		WorkspaceRoot: projectRoot,
		CreatedAt:     time.Now(),
		UpdatedAt:     time.Now(),
	}); err != nil {
		t.Fatalf("save branch meta: %v", err)
	}

	// Re-add under the flipped-case spelling — simulates Windows Explorer
	// or a different shell returning the same folder with different case.
	app := NewApp()
	installNoopRuntimeEvents(app)
	app.registerProjectRoot(flipped)

	// findTopicSessionForTarget must match the session whose meta carries
	// the original case spelling against the registry's new (flipped) root.
	path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
	if path == "" {
		t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
	}
	if path != sessionPath {
		t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
	}
}

func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
	parent := t.TempDir()
	missing := filepath.Join(parent, "deleted", "project")

	if got := dialogDefaultDirectory(missing); got != parent {
		t.Fatalf("dialogDefaultDirectory(%q) = %q, want %q", missing, got, parent)
	}
}

func TestDialogDefaultDirectoryUsesFileParent(t *testing.T) {
	dir := t.TempDir()
	file := filepath.Join(dir, "reasonix.toml")
	if err := os.WriteFile(file, []byte("default_model = \"x\"\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	if got := dialogDefaultDirectory(file); got != dir {
		t.Fatalf("dialogDefaultDirectory(file) = %q, want %q", got, dir)
	}
}

func TestDesktopSessionDirIsScopedByWorkspace(t *testing.T) {
	t.Setenv("HOME", t.TempDir())
	t.Setenv("USERPROFILE", t.TempDir())
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())

	rootA := filepath.Join(t.TempDir(), "project-a")
	rootB := filepath.Join(t.TempDir(), "project-b")
	if err := os.MkdirAll(rootA, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(rootB, 0o755); err != nil {
		t.Fatal(err)
	}

	dirA := desktopSessionDir(rootA)
	dirB := desktopSessionDir(rootB)
	if dirA == "" || dirB == "" {
		t.Fatalf("desktop session dirs should resolve: A=%q B=%q", dirA, dirB)
	}
	if dirA == dirB {
		t.Fatalf("different workspaces must not share a desktop session dir: %q", dirA)
	}
	if dirA == config.SessionDir() || dirB == config.SessionDir() {
		t.Fatalf("desktop workspace sessions should not use the global CLI session dir: A=%q B=%q global=%q", dirA, dirB, config.SessionDir())
	}
	wantPrefix := filepath.Join(config.MemoryUserDir(), "projects") + string(filepath.Separator)
	if !strings.HasPrefix(dirA, wantPrefix) || filepath.Base(dirA) != "sessions" {
		t.Fatalf("workspace session dir should live under the project state tree, got %q", dirA)
	}
}

func BenchmarkDesktopSessionDir(b *testing.B) {
	root := filepath.Join(b.TempDir(), "project")
	if err := os.MkdirAll(root, 0o755); err != nil {
		b.Fatal(err)
	}
	b.ReportAllocs()
	for range b.N {
		if desktopSessionDir(root) == "" {
			b.Fatal("empty session dir")
		}
	}
}

// cwdWritable

func TestCwdWritable(t *testing.T) {
	// In a normal test environment, cwd should be writable.
	if !cwdWritable() {
		t.Error("cwd should be writable in test environment")
	}
}

func TestCwdWritableInTempDir(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	os.Chdir(dir)
	if !cwdWritable() {
		t.Error("temp dir should be writable")
	}
}

func TestReadFileTrimsPartialUTF8RuneAtPreviewBoundary(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	prefix := strings.Repeat("a", filePreviewLimit-1)
	if err := os.WriteFile("large.md", []byte(prefix+"你tail"), 0o644); err != nil {
		t.Fatal(err)
	}

	preview := (&App{}).ReadFile("large.md")
	if preview.Err != "" {
		t.Fatalf("ReadFile err = %q", preview.Err)
	}
	if preview.Binary {
		t.Fatal("ReadFile marked valid truncated UTF-8 text as binary")
	}
	if !preview.Truncated {
		t.Fatal("ReadFile did not mark oversized file as truncated")
	}
	if preview.Body != prefix {
		t.Fatalf("ReadFile body len = %d, want %d", len(preview.Body), len(prefix))
	}
}

func TestReadFilePreviewBinaryClassification(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := robustTempDir(t)
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	// NUL is the binary signal, matching the CLI read_file tool once GB18030
	// decoding meant invalid UTF-8 alone no longer implies binary.
	if err := os.WriteFile("binary.bin", append([]byte("data"), 0x00, 0x01, 0x02), 0o644); err != nil {
		t.Fatal(err)
	}
	if p := (&App{}).ReadFile("binary.bin"); !p.Binary {
		t.Errorf("NUL-containing file should be binary, got Body=%q", p.Body)
	}

	// Invalid UTF-8 without a NUL is decoded leniently and shown as text, with
	// U+FFFD where bytes don't map — not hidden behind a binary classification.
	if err := os.WriteFile("invalid.txt", append([]byte("hello"), 0xff, 'x', 'y'), 0o644); err != nil {
		t.Fatal(err)
	}
	p := (&App{}).ReadFile("invalid.txt")
	if p.Binary {
		t.Error("invalid-but-NUL-free file should render as lossy text, not binary")
	}
	if !strings.ContainsRune(p.Body, '�') {
		t.Errorf("lossy decode should mark undecodable bytes with U+FFFD, got %q", p.Body)
	}
}

func TestReadFileMediaPreview(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	png := []byte("\x89PNG\r\n\x1a\npreview")
	if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
		t.Fatal(err)
	}
	var app App
	image := app.ReadFile("shot.PNG")
	if image.Err != "" {
		t.Fatalf("ReadFile png err = %q", image.Err)
	}
	if image.Binary || image.Kind != "image" || image.Mime != "image/png" {
		t.Fatalf("ReadFile png = %+v, want image preview", image)
	}
	if image.Body != "" {
		t.Fatalf("media preview should have empty body, got %q", image.Body)
	}
	if !strings.HasPrefix(image.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(image.URL, "/shot.PNG") {
		t.Fatalf("unexpected media URL: %q", image.URL)
	}

	if err := os.WriteFile("report.pdf", []byte("%PDF-1.4\npreview"), 0o644); err != nil {
		t.Fatal(err)
	}
	pdf := app.ReadFile("report.pdf")
	if pdf.Err != "" {
		t.Fatalf("ReadFile pdf err = %q", pdf.Err)
	}
	if pdf.Binary || pdf.Kind != "pdf" || pdf.Mime != "application/pdf" {
		t.Fatalf("ReadFile pdf = %+v, want pdf preview", pdf)
	}
	if pdf.Body != "" {
		t.Fatalf("media preview should have empty body, got %q", pdf.Body)
	}
	if !strings.HasPrefix(pdf.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(pdf.URL, "/report.pdf") {
		t.Fatalf("unexpected media URL: %q", pdf.URL)
	}
}

func TestMediaTokenHandlerServesFile(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	png := []byte("\x89PNG\r\n\x1a\npreview-data")
	if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile("shot.PNG")
	if preview.URL == "" {
		t.Fatal("expected URL in media preview")
	}

	mw := app.workspaceMediaMiddleware()
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("fallback handler should not be called for media URLs")
	}))

	req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
		t.Fatalf("expected Content-Type image/png, got %q", ct)
	}
	if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "inline") {
		t.Fatalf("expected inline Content-Disposition, got %q", cd)
	}
	if rec.Body.String() != string(png) {
		t.Fatalf("body mismatch, got %q", rec.Body.String())
	}
}

func TestMediaTokenHandlerEscapedFilename(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	name := `weird "file" name.png`
	rawURLChars := []string{" ", `"`}
	if runtime.GOOS == "windows" {
		name = "weird #file name.png"
		rawURLChars = []string{" ", "#"}
	}
	if err := os.WriteFile(name, []byte("png"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile(name)
	for _, raw := range rawURLChars {
		if strings.Contains(preview.URL, raw) {
			t.Fatalf("media URL should path-escape %q in filename, got %q", raw, preview.URL)
		}
	}

	handler := app.workspaceMediaMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("fallback handler should not be called")
	}))
	req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	disposition, params, err := mime.ParseMediaType(rec.Header().Get("Content-Disposition"))
	if err != nil {
		t.Fatalf("Content-Disposition should parse: %v", err)
	}
	if disposition != "inline" || params["filename"] != name {
		t.Fatalf("Content-Disposition = %q %#v, want inline filename %q", disposition, params, name)
	}
}

func TestMediaTokenHandlerHead(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile("doc.pdf", []byte("%PDF-test"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile("doc.pdf")

	mw := app.workspaceMediaMiddleware()
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("fallback handler should not be called")
	}))

	req := httptest.NewRequest(http.MethodHead, preview.URL, nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
		t.Fatalf("expected Content-Type application/pdf, got %q", ct)
	}
}

func TestMediaTokenHandlerRangeRequest(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	data := []byte("0123456789ABCDEF")
	if err := os.WriteFile("data.png", data, 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile("data.png")

	mw := app.workspaceMediaMiddleware()
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("fallback should not be called")
	}))

	req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
	req.Header.Set("Range", "bytes=0-4")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusPartialContent {
		t.Fatalf("expected 206, got %d", rec.Code)
	}
	if rec.Body.String() != "01234" {
		t.Fatalf("expected '01234', got %q", rec.Body.String())
	}
}

func TestMediaTokenHandlerBadToken(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	mw := app.workspaceMediaMiddleware()
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	req := httptest.NewRequest(http.MethodGet, "/__reasonix_workspace_media/deadbeef/fake.png", nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("expected 404 for bad token, got %d", rec.Code)
	}
}

func TestMediaTokenHandlerNonGetHead(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile("test.png", []byte("png"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile("test.png")

	mw := app.workspaceMediaMiddleware()
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("fallback should not be called")
	}))

	req := httptest.NewRequest(http.MethodPost, preview.URL, nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusMethodNotAllowed {
		t.Fatalf("expected 405 for POST, got %d", rec.Code)
	}
}

func TestMediaTokenHandlerPassesUnrelatedPaths(t *testing.T) {
	app := NewApp()
	mw := app.workspaceMediaMiddleware()

	fallbackCalled := false
	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fallbackCalled = true
		w.WriteHeader(http.StatusOK)
	}))

	req := httptest.NewRequest(http.MethodGet, "/index.html", nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if !fallbackCalled {
		t.Fatal("expected fallback handler for non-media path")
	}
}

func TestMediaTokenMaxEviction(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	store := app.mediaTokens

	// Fill beyond max to trigger eviction of oldest.
	var oldestToken string
	for i := range mediaTokenMax + 1 {
		tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
		if i == 0 {
			oldestToken = tok
		}
	}

	if store.get(oldestToken) != nil {
		t.Fatal("oldest token should have been evicted")
	}
	if len(store.order) != mediaTokenMax {
		t.Fatalf("expected %d tokens, got %d", mediaTokenMax, len(store.order))
	}
}

func TestMediaTokenExpiry(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	store := app.mediaTokens
	tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})

	// Force expiry by rolling back the clock on the entry.
	store.mu.Lock()
	store.byTok[tok].expiresAt = time.Now().Add(-1 * time.Second)
	store.mu.Unlock()

	if e := store.get(tok); e != nil {
		t.Fatal("expired token should return nil")
	}
	next := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
	if next == "" || store.get(next) == nil {
		t.Fatal("store should create and read a fresh token after expired get cleanup")
	}
}

func TestReadFileTextUnchanged(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile("hello.txt", []byte("hello world"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := NewApp()
	preview := app.ReadFile("hello.txt")
	if preview.Err != "" {
		t.Fatalf("ReadFile text err = %q", preview.Err)
	}
	if preview.Body != "hello world" {
		t.Fatalf("expected text body, got %q", preview.Body)
	}
	if preview.Kind != "" || preview.Mime != "" || preview.URL != "" {
		t.Fatalf("text preview should not have media fields")
	}
}

func TestReadFileGB18030(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	gb, _ := simplifiedchinese.GB18030.NewEncoder().String("你好世界")
	if err := os.WriteFile("gbk.txt", []byte(gb), 0o644); err != nil {
		t.Fatal(err)
	}

	preview := (&App{}).ReadFile("gbk.txt")
	if preview.Err != "" {
		t.Fatalf("ReadFile err = %q", preview.Err)
	}
	if preview.Binary {
		t.Fatal("ReadFile should decode GB18030, not mark as binary")
	}
	if !strings.Contains(preview.Body, "你好世界") {
		t.Errorf("expected decoded Chinese text, got %q", preview.Body)
	}
}

// RemoveWorkspace cleanup of active pointer

func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) {
	isolateDesktopUserDirs(t)
	if workspaceStatePath() == "" {
		t.Fatal("workspaceStatePath() is empty after isolating")
	}

	dir := t.TempDir()
	saveWorkspace(dir)
	if got := loadWorkspace(); got != dir {
		t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir)
	}

	// Simulate RemoveWorkspace's cleanup logic:
	// When the removed workspace equals the active one, clearWorkspace should fire.
	if loadWorkspace() == dir {
		clearWorkspace()
	}

	if got := loadWorkspace(); got != "" {
		t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got)
	}
}

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

	// Set up two projects and make the first one active.
	first := t.TempDir()
	second := t.TempDir()
	saveWorkspace(first)

	// Simulate: remove the active workspace, fall back to the other.
	if loadWorkspace() == first {
		// In the real code, loadProjectsFile() would return remaining projects.
		// Here we simulate falling back to the second project.
		saveWorkspace(second)
	}

	if got := loadWorkspace(); got != second {
		t.Errorf("loadWorkspace = %q, want fallback to %q", got, second)
	}
}

func TestClearWorkspace(t *testing.T) {
	isolateDesktopUserDirs(t)
	if workspaceStatePath() == "" {
		t.Fatal("workspaceStatePath() is empty after isolating")
	}

	dir := t.TempDir()
	saveWorkspace(dir)
	if got := loadWorkspace(); got != dir {
		t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir)
	}

	clearWorkspace()
	if got := loadWorkspace(); got != "" {
		t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got)
	}
	// Also verify the file is actually removed.
	if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) {
		t.Errorf("desktop-workspace file should be removed, stat err = %v", err)
	}
}

// OpenProjectTab updates active workspace pointer

func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) {
	isolateDesktopUserDirs(t)
	if workspaceStatePath() == "" {
		t.Fatal("workspaceStatePath() is empty after isolating")
	}

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

	if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil {
		t.Fatalf("open project tab: %v", err)
	}

	if got := loadWorkspace(); got != projectRoot {
		t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot)
	}
}

func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) {
	src, err := os.ReadFile("open_workspace_windows.go")
	if err != nil {
		t.Fatal(err)
	}
	body := string(src)
	if !strings.Contains(body, "ShellExecute") {
		t.Fatal("Windows workspace opener should use ShellExecute")
	}
	if strings.Contains(body, "cmd") || strings.Contains(body, "/c") {
		t.Fatal("Windows workspace opener must not route paths through cmd.exe")
	}
}

func TestParseGitStatusPorcelainZ(t *testing.T) {
	raw := []byte(" M changed.go\x00?? new.txt\x00R  renamed.go\x00old.go\x00")
	got := parseGitStatusPorcelainZ(raw)
	if len(got) != 3 {
		t.Fatalf("entries = %d, want 3: %+v", len(got), got)
	}
	if got[0].Path != "changed.go" || got[0].Status != "M" {
		t.Fatalf("modified entry = %+v", got[0])
	}
	if got[1].Path != "new.txt" || got[1].Status != "??" {
		t.Fatalf("untracked entry = %+v", got[1])
	}
	if got[2].Path != "renamed.go" || got[2].OldPath != "old.go" || got[2].Status != "R" {
		t.Fatalf("rename entry = %+v", got[2])
	}
}

func TestWorkspaceChangesNonGitDirectory(t *testing.T) {
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	got := (&App{}).WorkspaceChanges("")
	if got.GitAvailable {
		t.Fatal("non-git directory should mark git unavailable")
	}
	if len(got.Files) != 0 {
		t.Fatalf("files = %+v, want none", got.Files)
	}
}

func TestWorkspaceChangesUsesRequestedTabCheckpoints(t *testing.T) {
	workspace := t.TempDir()
	sessionDir := t.TempDir()
	sessionA := filepath.Join(sessionDir, "a.jsonl")
	sessionB := filepath.Join(sessionDir, "b.jsonl")
	content := "old"
	afterExists := true
	now := time.Now()

	for _, tc := range []struct {
		session       string
		path          string
		prompt        string
		schemaVersion int
		afterExisted  *bool
		afterSHA256   string
	}{
		{sessionA, "a.txt", "edit a", checkpoint.SchemaV2, &afterExists, checkpoint.Digest([]byte("new"))},
		{sessionB, "b.txt", "edit b", 0, nil, ""},
	} {
		ckptDir := strings.TrimSuffix(tc.session, ".jsonl") + ".ckpt"
		if err := os.MkdirAll(ckptDir, 0o755); err != nil {
			t.Fatal(err)
		}
		seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
			SchemaVersion: tc.schemaVersion,
			Turn:          0,
			Time:          now,
			Prompt:        tc.prompt,
			Files: []checkpoint.FileSnap{{
				Path: tc.path, Content: &content,
				AfterExisted: tc.afterExisted, AfterSHA256: tc.afterSHA256,
			}},
		})
	}

	ctrlA := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionA, Label: "a"})
	ctrlB := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionB, Label: "b"})
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"a": {ID: "a", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlA, Ready: true},
			"b": {ID: "b", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlB, Ready: true},
		},
		activeTabID: "a",
	}

	got := app.WorkspaceChanges("b")
	byPath := map[string]WorkspaceChangeView{}
	for _, file := range got.Files {
		byPath[file.Path] = file
	}
	if _, ok := byPath["a.txt"]; ok {
		t.Fatalf("requested tab b included active tab a changes: %+v", got.Files)
	}
	if byPath["b.txt"].LatestPrompt != "edit b" {
		t.Fatalf("requested tab b changes = %+v, want b.txt from tab b", got.Files)
	}
	if byPath["b.txt"].CanSessionRevert {
		t.Fatalf("legacy checkpoint must not enable destructive one-click revert: %+v", byPath["b.txt"])
	}
	gotA := app.WorkspaceChanges("a")
	if len(gotA.Files) != 1 || !gotA.Files[0].CanSessionRevert {
		t.Fatalf("verified v2 checkpoint should enable session revert: %+v", gotA.Files)
	}
}

func TestWorkspaceChangesGitStatus(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}
	runGit(t, "init")
	runGit(t, "checkout", "-b", "feature/test")
	if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "tracked.txt")
	if err := os.WriteFile("tracked.txt", []byte("v2\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile("untracked.txt", []byte("new\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	got := (&App{}).WorkspaceChanges("")
	if !got.GitAvailable {
		t.Fatalf("git unavailable: %s", got.GitErr)
	}
	if got.GitBranch != "feature/test" {
		t.Fatalf("git branch = %q, want feature/test", got.GitBranch)
	}
	byPath := map[string]WorkspaceChangeView{}
	for _, file := range got.Files {
		byPath[file.Path] = file
	}
	if byPath["tracked.txt"].GitStatus == "" {
		t.Fatalf("tracked.txt missing git status: %+v", got.Files)
	}
	if byPath["untracked.txt"].GitStatus != "??" {
		t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
	}
}

func TestWorkspaceChangesGitStatusFromRepoSubdirectory(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	repo := t.TempDir()
	if err := os.Chdir(repo); err != nil {
		t.Fatal(err)
	}
	runGit(t, "init")
	if err := os.MkdirAll("sub", 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", filepath.Join("sub", "tracked.txt"))
	if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v2\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join("sub", "untracked.txt"), []byte("new\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile("outside.txt", []byte("outside\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Chdir(filepath.Join(repo, "sub")); err != nil {
		t.Fatal(err)
	}

	got := (&App{}).WorkspaceChanges("")
	if !got.GitAvailable {
		t.Fatalf("git unavailable: %s", got.GitErr)
	}
	byPath := map[string]WorkspaceChangeView{}
	for _, file := range got.Files {
		byPath[file.Path] = file
	}
	if byPath["tracked.txt"].GitStatus == "" {
		t.Fatalf("tracked.txt missing git status: %+v", got.Files)
	}
	if byPath["untracked.txt"].GitStatus != "??" {
		t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
	}
	if _, ok := byPath["sub/tracked.txt"]; ok {
		t.Fatalf("git status path should be workspace-relative, got %+v", got.Files)
	}
	if _, ok := byPath["outside.txt"]; ok {
		t.Fatalf("changes outside the opened workspace should be hidden: %+v", got.Files)
	}
}

func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}
	runGit(t, "init")
	if err := os.MkdirAll(filepath.Join("newdir", "nested"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join("newdir", "nested", "file.txt"), []byte("new\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	got := (&App{}).WorkspaceChanges("")
	byPath := map[string]WorkspaceChangeView{}
	for _, file := range got.Files {
		byPath[file.Path] = file
	}
	if byPath["newdir/"].GitStatus != "" {
		t.Fatalf("directory should not be listed as a changed file: %+v", got.Files)
	}
	if byPath["newdir/nested/file.txt"].GitStatus != "??" {
		t.Fatalf("untracked file missing from directory: %+v", got.Files)
	}
}

func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}
	runGit(t, "init")
	runGit(t, "config", "user.email", "test@example.com")
	runGit(t, "config", "user.name", "Test User")
	if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "tracked.txt")
	runGit(t, "commit", "-m", "init")
	short := gitOutput(t, "rev-parse", "--short", "HEAD")
	runGit(t, "checkout", "--detach", "HEAD")

	got := (&App{}).WorkspaceChanges("")
	if !got.GitAvailable {
		t.Fatalf("git unavailable: %s", got.GitErr)
	}
	if got.GitBranch != "@"+short {
		t.Fatalf("git branch = %q, want @%s", got.GitBranch, short)
	}
}

func TestWorkspaceChangeDetailIncludesStagedAndUnstagedChanges(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	repo := t.TempDir()
	runGitIn(t, repo, "init")
	runGitIn(t, repo, "config", "user.email", "test@example.com")
	runGitIn(t, repo, "config", "user.name", "Test User")
	path := filepath.Join(repo, "tracked.txt")
	if err := os.WriteFile(path, []byte("v1\nkeep\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGitIn(t, repo, "add", "tracked.txt")
	runGitIn(t, repo, "commit", "-m", "initial")
	if err := os.WriteFile(path, []byte("v2\nkeep\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGitIn(t, repo, "add", "tracked.txt")
	if err := os.WriteFile(path, []byte("v3\nkeep\nnew\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
	detail, err := app.WorkspaceChangeDetail("tab", "tracked.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "git" || detail.Diff == nil {
		t.Fatalf("detail = %+v, want git patch", detail)
	}
	if !strings.Contains(*detail.Diff, "-v1") || !strings.Contains(*detail.Diff, "+v3") || strings.Contains(*detail.Diff, "+v2") {
		t.Fatalf("patch should describe HEAD to current worktree, got:\n%s", *detail.Diff)
	}
	if detail.Added != 2 || detail.Removed != 1 {
		t.Fatalf("tallies = +%d/-%d, want +2/-1", detail.Added, detail.Removed)
	}
}

func TestWorkspaceChangeDetailSynthesizesUntrackedFile(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	repo := t.TempDir()
	runGitIn(t, repo, "init")
	if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
	detail, err := app.WorkspaceChangeDetail("tab", "new.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "git" || detail.Diff == nil || !strings.Contains(*detail.Diff, "+one") {
		t.Fatalf("untracked detail = %+v", detail)
	}
	if detail.Added != 2 || detail.Removed != 0 {
		t.Fatalf("untracked tallies = +%d/-%d, want +2/-0", detail.Added, detail.Removed)
	}
}

func TestWorkspaceChangeDetailBoundsTrackedPatch(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	repo := t.TempDir()
	runGitIn(t, repo, "init")
	runGitIn(t, repo, "config", "user.email", "test@example.com")
	runGitIn(t, repo, "config", "user.name", "Test User")
	path := filepath.Join(repo, "large.txt")
	if err := os.WriteFile(path, []byte(strings.Repeat("a", workspaceChangeDetailLimit+128)), 0o644); err != nil {
		t.Fatal(err)
	}
	runGitIn(t, repo, "add", "large.txt")
	runGitIn(t, repo, "commit", "-m", "initial")
	if err := os.WriteFile(path, []byte(strings.Repeat("b", workspaceChangeDetailLimit+128)), 0o644); err != nil {
		t.Fatal(err)
	}

	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
	detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "git" || !detail.Truncated || detail.Diff != nil {
		t.Fatalf("large tracked detail = %+v, want bounded git result", detail)
	}
}

func TestWorkspaceChangeDetailBoundsUntrackedFile(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	repo := t.TempDir()
	runGitIn(t, repo, "init")
	if err := os.WriteFile(filepath.Join(repo, "large.txt"), []byte(strings.Repeat("x", workspaceChangeDetailLimit+1)), 0o644); err != nil {
		t.Fatal(err)
	}

	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
	detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "git" || !detail.Truncated || detail.Diff != nil {
		t.Fatalf("large untracked detail = %+v, want bounded git result", detail)
	}
}

func TestWorkspaceChangeDetailBoundsCheckpointSnapshot(t *testing.T) {
	workspace := t.TempDir()
	sessionDir := t.TempDir()
	sessionPath := filepath.Join(sessionDir, "session.jsonl")
	checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"
	if err := os.MkdirAll(checkpointDir, 0o755); err != nil {
		t.Fatal(err)
	}
	original := strings.Repeat("before", workspaceChangeDetailLimit/6+1)
	seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{
		Turn:  0,
		Time:  time.Now(),
		Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "large.txt"), Content: &original}},
	})
	if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte("after\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	ctrl := control.New(control.Options{
		SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session",
	})
	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}}
	detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "session" || !detail.Truncated || detail.Diff != nil {
		t.Fatalf("large checkpoint detail = %+v, want bounded session result", detail)
	}
}

func TestWorkspaceChangeDetailFallsBackToRequestedTabCheckpoint(t *testing.T) {
	workspace := t.TempDir()
	sessionDir := t.TempDir()
	sessionPath := filepath.Join(sessionDir, "session.jsonl")
	checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"
	if err := os.MkdirAll(checkpointDir, 0o755); err != nil {
		t.Fatal(err)
	}
	original := "before\n"
	seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{
		Turn:  0,
		Time:  time.Now(),
		Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "file.txt"), Content: &original}},
	})
	if err := os.WriteFile(filepath.Join(workspace, "file.txt"), []byte("after\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	ctrl := control.New(control.Options{
		SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session",
	})
	app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}}
	detail, err := app.WorkspaceChangeDetail("tab", "file.txt")
	if err != nil {
		t.Fatal(err)
	}
	if detail.Source != "session" || detail.Diff == nil || !strings.Contains(*detail.Diff, "-before") || !strings.Contains(*detail.Diff, "+after") {
		t.Fatalf("checkpoint detail = %+v", detail)
	}
	if _, err := app.WorkspaceChangeDetail("tab", "../outside.txt"); err == nil {
		t.Fatal("WorkspaceChangeDetail accepted a path outside the workspace")
	}
}

func TestWorkspaceGitHistory(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	runGit(t, "init")
	runGit(t, "config", "user.email", "test@example.com")
	runGit(t, "config", "user.name", "Test User")

	if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "file1.txt")
	runGit(t, "commit", "-m", "init file1")

	if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "file2.txt")
	runGit(t, "commit", "-m", "init file2")

	app := &App{}
	history, err := app.WorkspaceGitHistory("", "")
	if err != nil {
		t.Fatalf("WorkspaceGitHistory err = %v", err)
	}
	if len(history) != 2 {
		t.Fatalf("expected 2 commits, got %d", len(history))
	}
	if history[0].Message != "init file2" {
		t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message)
	}
	if history[1].Message != "init file1" {
		t.Errorf("expected older commit message 'init file1', got %q", history[1].Message)
	}

	// Test history for specific file
	history, err = app.WorkspaceGitHistory("", "file1.txt")
	if err != nil {
		t.Fatalf("WorkspaceGitHistory err = %v", err)
	}
	if len(history) != 1 {
		t.Fatalf("expected 1 commit for file1.txt, got %d", len(history))
	}
	if history[0].Message != "init file1" {
		t.Errorf("expected commit message 'init file1', got %q", history[0].Message)
	}
}

func TestEmptyGitArrayResultsAreNonNil(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}
	runGit(t, "init")
	runGit(t, "config", "user.email", "test@example.com")
	runGit(t, "config", "user.name", "Test User")

	app := &App{}
	branches, err := app.GitBranches()
	if err != nil {
		t.Fatalf("GitBranches err = %v", err)
	}
	if branches == nil {
		t.Fatal("GitBranches returned nil for an unborn repository; frontend expects []")
	}

	if err := os.WriteFile("tracked.txt", []byte("content\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "tracked.txt")
	runGit(t, "commit", "-m", "initial")

	history, err := app.WorkspaceGitHistory("", "never-existed.txt")
	if err != nil {
		t.Fatalf("WorkspaceGitHistory empty path err = %v", err)
	}
	if history == nil {
		t.Fatal("WorkspaceGitHistory returned nil for a path without commits; frontend expects []")
	}
}

func TestWorkspaceGitHistoryUsesRequestedTabWorkspace(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	makeRepo := func(name, message string) string {
		t.Helper()
		dir := filepath.Join(t.TempDir(), name)
		if err := os.MkdirAll(dir, 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.Chdir(dir); err != nil {
			t.Fatal(err)
		}
		runGit(t, "init")
		runGit(t, "config", "user.email", "test@example.com")
		runGit(t, "config", "user.name", "Test User")
		if err := os.WriteFile("file.txt", []byte(message+"\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		runGit(t, "add", "file.txt")
		runGit(t, "commit", "-m", message)
		return dir
	}

	repoA := makeRepo("a", "repo a commit")
	repoB := makeRepo("b", "repo b commit")
	app := &App{
		tabs: map[string]*WorkspaceTab{
			"a": {ID: "a", Scope: "project", WorkspaceRoot: repoA, Ready: true},
			"b": {ID: "b", Scope: "project", WorkspaceRoot: repoB, Ready: true},
		},
		activeTabID: "a",
	}

	history, err := app.WorkspaceGitHistory("b", "")
	if err != nil {
		t.Fatalf("WorkspaceGitHistory err = %v", err)
	}
	if len(history) != 1 || history[0].Message != "repo b commit" {
		t.Fatalf("history for requested tab = %+v, want repo b commit", history)
	}
}

func TestWorkspaceGitCommitDetail(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	orig, _ := os.Getwd()
	defer os.Chdir(orig)

	dir := t.TempDir()
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}

	runGit(t, "init")
	runGit(t, "config", "user.email", "test@example.com")
	runGit(t, "config", "user.name", "Test User")

	if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "file1.txt")
	runGit(t, "commit", "-m", "init file1")

	if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, "add", "file1.txt")
	runGit(t, "commit", "-m", "update file1")

	hash := gitOutput(t, "rev-parse", "HEAD")

	app := &App{}

	// Test project level detail
	detail, err := app.WorkspaceGitCommitDetail("", hash, "")
	if err != nil {
		t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
	}
	if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" {
		t.Fatalf("expected files [file1.txt], got %v", detail.Files)
	}
	if detail.Diff != nil {
		t.Fatal("expected nil diff for project level")
	}

	// Test file level detail
	detail, err = app.WorkspaceGitCommitDetail("", hash, "file1.txt")
	if err != nil {
		t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
	}
	if len(detail.Files) != 0 {
		t.Fatalf("expected no files for file level, got %v", detail.Files)
	}
	if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") {
		t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff)
	}
}

func runGit(t *testing.T, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %v: %v\n%s", args, err, out)
	}
}

func runGitIn(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
	}
}

func gitOutput(t *testing.T, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %v: %v\n%s", args, err, out)
	}
	return strings.TrimSpace(string(out))
}

// settings_app.go helpers
// These are unexported but in the same package, so we can test them.

func TestOrDefault(t *testing.T) {
	if orDefault("", "fallback") != "fallback" {
		t.Error("empty should return default")
	}
	if orDefault("value", "fallback") != "value" {
		t.Error("non-empty should return value")
	}
}

func TestTrimList(t *testing.T) {
	got := trimList([]string{"  a  ", "", " b ", "  "})
	if len(got) != 2 || got[0] != "a" || got[1] != "b" {
		t.Errorf("trimList = %v", got)
	}
}

func TestTrimListEmpty(t *testing.T) {
	got := trimList(nil)
	if len(got) != 0 {
		t.Errorf("nil = %v, want empty", got)
	}
}

func TestNonNil(t *testing.T) {
	if got := nonNil(nil); got == nil || len(got) != 0 {
		t.Errorf("nonNil(nil) = %v, want empty non-nil", got)
	}
	s := []string{"a"}
	if got := nonNil(s); got[0] != "a" {
		t.Errorf("nonNil should pass through")
	}
}
