package main

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/gofrs/flock"
	"github.com/steveyegge/beads/internal/storage/sqlite"
	"github.com/steveyegge/beads/internal/syncbranch"
	"github.com/steveyegge/beads/internal/types"
)

func TestIsGitRepo_InGitRepo(t *testing.T) {
	// This test assumes we're running in the beads git repo
	if !isGitRepo() {
		t.Skip("not in a git repository")
	}
}

func TestIsGitRepo_NotInGitRepo(t *testing.T) {
	tmpDir := t.TempDir()
	t.Chdir(tmpDir)

	if isGitRepo() {
		t.Error("expected false when not in git repo")
	}
}

func TestGitHasUpstream_NoUpstream(t *testing.T) {
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Should not have upstream
	if gitHasUpstream() {
		t.Error("expected false when no upstream configured")
	}
}

func TestGitHasChanges_NoFile(t *testing.T) {
	ctx := context.Background()
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Check - should have no changes (test.txt was committed by setupGitRepo)
	hasChanges, err := gitHasChanges(ctx, "test.txt")
	if err != nil {
		t.Fatalf("gitHasChanges() error = %v", err)
	}
	if hasChanges {
		t.Error("expected no changes for committed file")
	}
}

func TestGitHasChanges_ModifiedFile(t *testing.T) {
	ctx := context.Background()
	tmpDir, cleanup := setupGitRepo(t)
	defer cleanup()

	// Modify the file
	testFile := filepath.Join(tmpDir, "test.txt")
	os.WriteFile(testFile, []byte("modified"), 0644)

	// Check - should have changes
	hasChanges, err := gitHasChanges(ctx, "test.txt")
	if err != nil {
		t.Fatalf("gitHasChanges() error = %v", err)
	}
	if !hasChanges {
		t.Error("expected changes for modified file")
	}
}

func TestGitHasUnmergedPaths_CleanRepo(t *testing.T) {
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Should not have unmerged paths
	hasUnmerged, err := gitHasUnmergedPaths()
	if err != nil {
		t.Fatalf("gitHasUnmergedPaths() error = %v", err)
	}
	if hasUnmerged {
		t.Error("expected no unmerged paths in clean repo")
	}
}

func TestGitCommit_Success(t *testing.T) {
	ctx := context.Background()
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Create a new file
	testFile := "new.txt"
	os.WriteFile(testFile, []byte("content"), 0644)

	// Commit the file
	err := gitCommit(ctx, testFile, "test commit")
	if err != nil {
		t.Fatalf("gitCommit() error = %v", err)
	}

	// Verify file is committed
	hasChanges, err := gitHasChanges(ctx, testFile)
	if err != nil {
		t.Fatalf("gitHasChanges() error = %v", err)
	}
	if hasChanges {
		t.Error("expected no changes after commit")
	}
}

func TestGitCommit_AutoMessage(t *testing.T) {
	ctx := context.Background()
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Create a new file
	testFile := "new.txt"
	os.WriteFile(testFile, []byte("content"), 0644)

	// Commit with auto-generated message (empty string)
	err := gitCommit(ctx, testFile, "")
	if err != nil {
		t.Fatalf("gitCommit() error = %v", err)
	}

	// Verify it committed (message generation worked)
	cmd := exec.Command("git", "log", "-1", "--pretty=%B")
	output, _ := cmd.Output()
	if len(output) == 0 {
		t.Error("expected commit message to be generated")
	}
}

func TestCountIssuesInJSONL_NonExistent(t *testing.T) {
	t.Parallel()
	count, err := countIssuesInJSONL("/nonexistent/path.jsonl")
	if err == nil {
		t.Error("expected error for nonexistent file")
	}
	if count != 0 {
		t.Errorf("count = %d, want 0 on error", count)
	}
}

func TestCountIssuesInJSONL_EmptyFile(t *testing.T) {
	t.Parallel()
	tmpDir := t.TempDir()
	jsonlPath := filepath.Join(tmpDir, "empty.jsonl")
	os.WriteFile(jsonlPath, []byte(""), 0644)

	count, err := countIssuesInJSONL(jsonlPath)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if count != 0 {
		t.Errorf("count = %d, want 0", count)
	}
}

func TestCountIssuesInJSONL_MultipleIssues(t *testing.T) {
	t.Parallel()
	tmpDir := t.TempDir()
	jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
	content := `{"id":"bd-1"}
{"id":"bd-2"}
{"id":"bd-3"}
`
	os.WriteFile(jsonlPath, []byte(content), 0644)

	count, err := countIssuesInJSONL(jsonlPath)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if count != 3 {
		t.Errorf("count = %d, want 3", count)
	}
}

func TestCountIssuesInJSONL_WithMalformedLines(t *testing.T) {
	t.Parallel()
	tmpDir := t.TempDir()
	jsonlPath := filepath.Join(tmpDir, "mixed.jsonl")
	content := `{"id":"bd-1"}
not valid json
{"id":"bd-2"}
{"id":"bd-3"}
`
	os.WriteFile(jsonlPath, []byte(content), 0644)

	count, err := countIssuesInJSONL(jsonlPath)
	// countIssuesInJSONL returns error on malformed JSON
	if err == nil {
		t.Error("expected error for malformed JSON")
	}
	// Should have counted the first valid issue before hitting error
	if count != 1 {
		t.Errorf("count = %d, want 1 (before malformed line)", count)
	}
}

func TestGetCurrentBranch(t *testing.T) {
	ctx := context.Background()
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Get current branch
	branch, err := getCurrentBranch(ctx)
	if err != nil {
		t.Fatalf("getCurrentBranch() error = %v", err)
	}

	// Default branch is usually main or master
	if branch != "main" && branch != "master" {
		t.Logf("got branch %s (expected main or master, but this can vary)", branch)
	}
}

func TestMergeSyncBranch_NoSyncBranchConfigured(t *testing.T) {
	ctx := context.Background()
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Try to merge without sync.branch configured (or database)
	err := mergeSyncBranch(ctx, false)
	if err == nil {
		t.Error("expected error when sync.branch not configured")
	}
	// Error could be about missing database or missing sync.branch config
	if err != nil && !strings.Contains(err.Error(), "sync.branch") && !strings.Contains(err.Error(), "database") {
		t.Errorf("expected error about sync.branch or database, got: %v", err)
	}
}

func TestMergeSyncBranch_OnSyncBranch(t *testing.T) {
	ctx := context.Background()
	tmpDir, cleanup := setupGitRepo(t)
	defer cleanup()

	// Create sync branch
	exec.Command("git", "checkout", "-b", "beads-metadata").Run()

	// Initialize bd database and set sync.branch
	beadsDir := filepath.Join(tmpDir, ".beads")
	os.MkdirAll(beadsDir, 0755)

	// This test will fail with store access issues, so we just verify the branch check
	// The actual merge functionality is tested in integration tests
	currentBranch, _ := getCurrentBranch(ctx)
	if currentBranch != "beads-metadata" {
		t.Skipf("test setup failed, current branch is %s", currentBranch)
	}
}

func TestMergeSyncBranch_DirtyWorkingTree(t *testing.T) {
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Create uncommitted changes
	os.WriteFile("test.txt", []byte("modified"), 0644)

	// This test verifies the dirty working tree check would work
	// (We can't test the full merge without database setup)
	statusCmd := exec.Command("git", "status", "--porcelain")
	output, _ := statusCmd.Output()
	if len(output) == 0 {
		t.Error("expected dirty working tree for test setup")
	}
}

func TestGetSyncBranch_EnvOverridesDB(t *testing.T) {
	ctx := context.Background()

	// Save and restore global store state
	oldStore := store
	storeMutex.Lock()
	oldStoreActive := storeActive
	storeMutex.Unlock()
	oldDBPath := dbPath

	// Use an in-memory SQLite store for testing
	testStore, err := sqlite.New(context.Background(), "file::memory:?mode=memory&cache=private")
	if err != nil {
		t.Fatalf("failed to create test store: %v", err)
	}
	defer testStore.Close()

	// Seed DB config and globals
	if err := testStore.SetConfig(ctx, "sync.branch", "db-branch"); err != nil {
		t.Fatalf("failed to set sync.branch in db: %v", err)
	}

	storeMutex.Lock()
	store = testStore
	storeActive = true
	storeMutex.Unlock()
	dbPath = "" // avoid FindDatabasePath in ensureStoreActive

	// Set environment override
	if err := os.Setenv(syncbranch.EnvVar, "env-branch"); err != nil {
		t.Fatalf("failed to set %s: %v", syncbranch.EnvVar, err)
	}
	defer os.Unsetenv(syncbranch.EnvVar)

	// Ensure we restore globals after the test
	defer func() {
		storeMutex.Lock()
		store = oldStore
		storeActive = oldStoreActive
		storeMutex.Unlock()
		dbPath = oldDBPath
	}()

	branch, err := getSyncBranch(ctx)
	if err != nil {
		t.Fatalf("getSyncBranch() error = %v", err)
	}
	if branch != "env-branch" {
		t.Errorf("getSyncBranch() = %q, want %q (env override)", branch, "env-branch")
	}
}

func TestIsInRebase_NotInRebase(t *testing.T) {
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Should not be in rebase
	if isInRebase() {
		t.Error("expected false when not in rebase")
	}
}

func TestIsInRebase_InRebase(t *testing.T) {
	tmpDir, cleanup := setupGitRepo(t)
	defer cleanup()

	// Simulate rebase by creating rebase-merge directory
	os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-merge"), 0755)

	// Should detect rebase
	if !isInRebase() {
		t.Error("expected true when .git/rebase-merge exists")
	}
}

func TestIsInRebase_InRebaseApply(t *testing.T) {
	tmpDir, cleanup := setupMinimalGitRepo(t)
	defer cleanup()

	// Simulate non-interactive rebase by creating rebase-apply directory
	os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-apply"), 0755)

	// Should detect rebase
	if !isInRebase() {
		t.Error("expected true when .git/rebase-apply exists")
	}
}

func TestHasJSONLConflict_NoConflict(t *testing.T) {
	_, cleanup := setupGitRepo(t)
	defer cleanup()

	// Should not have JSONL conflict
	if hasJSONLConflict() {
		t.Error("expected false when no conflicts")
	}
}

func TestHasJSONLConflict_OnlyJSONLConflict(t *testing.T) {
	tmpDir, cleanup := setupGitRepoWithBranch(t, "main")
	defer cleanup()

	// Create initial commit with beads.jsonl
	beadsDir := filepath.Join(tmpDir, ".beads")
	os.MkdirAll(beadsDir, 0755)
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "add beads.jsonl").Run()

	// Create a second commit on main (modify same issue)
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "main change").Run()

	// Create a branch from the first commit
	exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "feature change").Run()

	// Attempt rebase onto main (will conflict)
	exec.Command("git", "rebase", "main").Run()

	// Should detect JSONL conflict during rebase
	if !hasJSONLConflict() {
		t.Error("expected true when only beads.jsonl has conflict during rebase")
	}
}

func TestHasJSONLConflict_MultipleConflicts(t *testing.T) {
	tmpDir, cleanup := setupGitRepoWithBranch(t, "main")
	defer cleanup()

	// Create initial commit with beads.jsonl and another file
	beadsDir := filepath.Join(tmpDir, ".beads")
	os.MkdirAll(beadsDir, 0755)
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
	os.WriteFile("other.txt", []byte("line1\nline2\nline3"), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "add initial files").Run()

	// Create a second commit on main (modify both files)
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
	os.WriteFile("other.txt", []byte("line1\nmain-version\nline3"), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "main change").Run()

	// Create a branch from the first commit
	exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
	os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
	os.WriteFile("other.txt", []byte("line1\nfeature-version\nline3"), 0644)
	exec.Command("git", "add", ".").Run()
	exec.Command("git", "commit", "-m", "feature change").Run()

	// Attempt rebase (will conflict on both files)
	exec.Command("git", "rebase", "main").Run()

	// Should NOT auto-resolve when multiple files conflict
	if hasJSONLConflict() {
		t.Error("expected false when multiple files have conflicts (should not auto-resolve)")
	}
}

// Note: TestZFCSkipsExportAfterImport was removed as ZFC checks are no longer part of the
// legacy sync flow. Use --pull-first for structural staleness handling via 3-way merge.

// TestHashBasedStalenessDetection_bd_f2f tests the bd-f2f fix:
// When JSONL content differs from stored hash (e.g., remote changed status),
// hasJSONLChanged should detect the mismatch even if counts are equal.
func TestHashBasedStalenessDetection_bd_f2f(t *testing.T) {
	ctx := context.Background()
	tmpDir := t.TempDir()

	// Create test database
	beadsDir := filepath.Join(tmpDir, ".beads")
	if err := os.MkdirAll(beadsDir, 0755); err != nil {
		t.Fatalf("failed to create beads dir: %v", err)
	}

	testDBPath := filepath.Join(beadsDir, "beads.db")
	jsonlPath := filepath.Join(beadsDir, "issues.jsonl")

	// Create store
	testStore, err := sqlite.New(ctx, testDBPath)
	if err != nil {
		t.Fatalf("failed to create store: %v", err)
	}
	defer testStore.Close()

	// Initialize issue prefix (required for creating issues)
	if err := testStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
		t.Fatalf("failed to set issue prefix: %v", err)
	}

	// Create an issue in DB (simulating stale DB with old content)
	issue := &types.Issue{
		ID:        "test-abc",
		Title:     "Test Issue",
		Status:    types.StatusOpen,
		Priority:  1, // DB has priority 1
		IssueType: types.TypeTask,
	}
	if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
		t.Fatalf("failed to create issue: %v", err)
	}

	// Create JSONL with same issue but different priority (correct remote state)
	// This simulates what happens after git pull brings in updated JSONL
	// (e.g., remote changed priority from 1 to 0)
	jsonlContent := `{"id":"test-abc","title":"Test Issue","status":"open","priority":0,"type":"task"}
`
	if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil {
		t.Fatalf("failed to write JSONL: %v", err)
	}

	// Store an OLD hash (different from current JSONL)
	// This simulates the case where JSONL was updated externally (by git pull)
	// but DB still has old hash from before the pull
	oldHash := "0000000000000000000000000000000000000000000000000000000000000000"
	if err := testStore.SetMetadata(ctx, "jsonl_content_hash", oldHash); err != nil {
		t.Fatalf("failed to set old hash: %v", err)
	}

	// Verify counts are equal (1 issue in both)
	dbCount, err := countDBIssuesFast(ctx, testStore)
	if err != nil {
		t.Fatalf("failed to count DB issues: %v", err)
	}
	jsonlCount, err := countIssuesInJSONL(jsonlPath)
	if err != nil {
		t.Fatalf("failed to count JSONL issues: %v", err)
	}
	if dbCount != jsonlCount {
		t.Fatalf("setup error: expected equal counts, got DB=%d, JSONL=%d", dbCount, jsonlCount)
	}

	// The key test: hasJSONLChanged should detect the hash mismatch
	// even though counts are equal
	repoKey := getRepoKeyForPath(jsonlPath)
	changed := hasJSONLChanged(ctx, testStore, jsonlPath, repoKey)

	if !changed {
		t.Error("bd-f2f: hasJSONLChanged should return true when JSONL hash differs from stored hash")
		t.Log("This is the bug scenario: counts match (1 == 1) but content differs (priority=1 vs priority=0)")
		t.Log("Without the bd-f2f fix, the stale DB would export old content and corrupt the remote")
	} else {
		t.Log("✓ bd-f2f fix verified: hash mismatch detected even with equal counts")
	}

	// Verify that after updating hash, hasJSONLChanged returns false
	currentHash, err := computeJSONLHash(jsonlPath)
	if err != nil {
		t.Fatalf("failed to compute current hash: %v", err)
	}
	if err := testStore.SetMetadata(ctx, "jsonl_content_hash", currentHash); err != nil {
		t.Fatalf("failed to set current hash: %v", err)
	}

	changedAfterUpdate := hasJSONLChanged(ctx, testStore, jsonlPath, repoKey)
	if changedAfterUpdate {
		t.Error("hasJSONLChanged should return false after hash is updated to match JSONL")
	}
}

// TestResolveNoGitHistoryForFromMain tests that --from-main forces noGitHistory=true
// to prevent creating incorrect deletion records for locally-created beads.
// See: https://github.com/steveyegge/beads/issues/417
func TestResolveNoGitHistoryForFromMain(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name         string
		fromMain     bool
		noGitHistory bool
		want         bool
	}{
		{
			name:         "fromMain=true forces noGitHistory=true regardless of flag",
			fromMain:     true,
			noGitHistory: false,
			want:         true,
		},
		{
			name:         "fromMain=true with noGitHistory=true stays true",
			fromMain:     true,
			noGitHistory: true,
			want:         true,
		},
		{
			name:         "fromMain=false preserves noGitHistory=false",
			fromMain:     false,
			noGitHistory: false,
			want:         false,
		},
		{
			name:         "fromMain=false preserves noGitHistory=true",
			fromMain:     false,
			noGitHistory: true,
			want:         true,
		},
	}

	for _, tt := range tests {
		tt := tt // capture range variable
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := resolveNoGitHistoryForFromMain(tt.fromMain, tt.noGitHistory)
			if got != tt.want {
				t.Errorf("resolveNoGitHistoryForFromMain(%v, %v) = %v, want %v",
					tt.fromMain, tt.noGitHistory, got, tt.want)
			}
		})
	}
}

// TestGetGitCommonDir tests that getGitCommonDir correctly returns the shared
// git directory for both regular repos and worktrees.
func TestGetGitCommonDir(t *testing.T) {
	ctx := context.Background()

	// Test 1: Regular repo
	t.Run("regular repo", func(t *testing.T) {
		repoDir, cleanup := setupGitRepo(t)
		defer cleanup()

		commonDir, err := getGitCommonDir(ctx, repoDir)
		if err != nil {
			t.Fatalf("getGitCommonDir failed: %v", err)
		}

		// For a regular repo, git-common-dir should point to .git
		expectedGitDir := filepath.Join(repoDir, ".git")
		// Resolve symlinks for comparison (macOS /var -> /private/var)
		if resolved, err := filepath.EvalSymlinks(expectedGitDir); err == nil {
			expectedGitDir = resolved
		}
		if commonDir != expectedGitDir {
			t.Errorf("getGitCommonDir = %q, want %q", commonDir, expectedGitDir)
		}
	})

	// Test 2: Worktree (non-bare) shares common dir with main repo
	t.Run("worktree shares common dir with main repo", func(t *testing.T) {
		repoDir, cleanup := setupGitRepo(t)
		defer cleanup()

		// Create a branch for the worktree
		if err := exec.Command("git", "-C", repoDir, "branch", "test-branch").Run(); err != nil {
			t.Fatalf("git branch failed: %v", err)
		}

		// Create worktree
		worktreeDir := filepath.Join(t.TempDir(), "worktree")
		if output, err := exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir, "test-branch").CombinedOutput(); err != nil {
			t.Fatalf("git worktree add failed: %v\n%s", err, output)
		}

		// Get common dir for both
		mainCommonDir, err := getGitCommonDir(ctx, repoDir)
		if err != nil {
			t.Fatalf("getGitCommonDir(main) failed: %v", err)
		}

		worktreeCommonDir, err := getGitCommonDir(ctx, worktreeDir)
		if err != nil {
			t.Fatalf("getGitCommonDir(worktree) failed: %v", err)
		}

		// Both should return the same common dir
		if mainCommonDir != worktreeCommonDir {
			t.Errorf("common dirs differ: main=%q, worktree=%q", mainCommonDir, worktreeCommonDir)
		}
	})
}

// TestIsExternalBeadsDir tests that isExternalBeadsDir correctly identifies
// when beads directory is in the same vs different git repo.
// GH#810: This was broken for bare repo worktrees.
func TestIsExternalBeadsDir(t *testing.T) {
	ctx := context.Background()

	// Test 1: Same directory - not external
	t.Run("same directory is not external", func(t *testing.T) {
		repoDir, cleanup := setupGitRepo(t)
		defer cleanup()

		beadsDir := filepath.Join(repoDir, ".beads")
		if err := os.MkdirAll(beadsDir, 0750); err != nil {
			t.Fatalf("mkdir failed: %v", err)
		}

		// Change to the repo directory (isExternalBeadsDir uses cwd)
		origDir, _ := os.Getwd()
		if err := os.Chdir(repoDir); err != nil {
			t.Fatalf("chdir failed: %v", err)
		}
		defer func() { _ = os.Chdir(origDir) }()

		if isExternalBeadsDir(ctx, beadsDir) {
			t.Error("expected local beads dir to not be external")
		}
	})

	// Test 2: Different repo - is external
	t.Run("different repo is external", func(t *testing.T) {
		repo1Dir, cleanup1 := setupGitRepo(t)
		defer cleanup1()
		repo2Dir, cleanup2 := setupGitRepo(t)
		defer cleanup2()

		beadsDir := filepath.Join(repo2Dir, ".beads")
		if err := os.MkdirAll(beadsDir, 0750); err != nil {
			t.Fatalf("mkdir failed: %v", err)
		}

		// Change to repo1
		origDir, _ := os.Getwd()
		if err := os.Chdir(repo1Dir); err != nil {
			t.Fatalf("chdir failed: %v", err)
		}
		defer func() { _ = os.Chdir(origDir) }()

		if !isExternalBeadsDir(ctx, beadsDir) {
			t.Error("expected beads dir in different repo to be external")
		}
	})

	// Test 3: Worktree with beads - not external (GH#810 fix)
	t.Run("worktree beads dir is not external", func(t *testing.T) {
		repoDir, cleanup := setupGitRepo(t)
		defer cleanup()

		// Create a branch for the worktree
		if err := exec.Command("git", "-C", repoDir, "branch", "test-branch").Run(); err != nil {
			t.Fatalf("git branch failed: %v", err)
		}

		// Create worktree
		worktreeDir := filepath.Join(t.TempDir(), "worktree")
		if output, err := exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir, "test-branch").CombinedOutput(); err != nil {
			t.Fatalf("git worktree add failed: %v\n%s", err, output)
		}

		// Create beads dir in worktree
		beadsDir := filepath.Join(worktreeDir, ".beads")
		if err := os.MkdirAll(beadsDir, 0750); err != nil {
			t.Fatalf("mkdir failed: %v", err)
		}

		// Change to worktree
		origDir, _ := os.Getwd()
		if err := os.Chdir(worktreeDir); err != nil {
			t.Fatalf("chdir failed: %v", err)
		}
		defer func() { _ = os.Chdir(origDir) }()

		// Beads dir in same worktree should NOT be external
		if isExternalBeadsDir(ctx, beadsDir) {
			t.Error("expected beads dir in same worktree to not be external")
		}
	})
}

// TestConcurrentEdit tests the pull-first sync flow with concurrent edits.
// This validates the 3-way merge logic for the pull-first sync refactor (#911).
//
// Scenario:
// - Base state exists (issue bd-1 at version 2025-01-01)
// - Local modifies issue (version 2025-01-02)
// - Remote also modifies issue (version 2025-01-03)
// - 3-way merge detects conflict and resolves using LWW (remote wins)
func TestConcurrentEdit(t *testing.T) {
	ctx := context.Background()
	tmpDir := t.TempDir()
	t.Chdir(tmpDir)

	// Setup: Initialize git repo
	if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
		t.Fatalf("git init failed: %v", err)
	}
	_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
	_ = exec.Command("git", "config", "user.name", "Test User").Run()

	// Setup: Create beads directory with JSONL (base state)
	beadsDir := filepath.Join(tmpDir, ".beads")
	if err := os.MkdirAll(beadsDir, 0755); err != nil {
		t.Fatalf("mkdir failed: %v", err)
	}
	jsonlPath := filepath.Join(beadsDir, "issues.jsonl")

	// Base state: single issue at 2025-01-01
	baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseIssue := `{"id":"bd-1","title":"Original Title","status":"open","issue_type":"task","priority":2,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}`
	if err := os.WriteFile(jsonlPath, []byte(baseIssue+"\n"), 0644); err != nil {
		t.Fatalf("write JSONL failed: %v", err)
	}

	// Initial commit
	_ = exec.Command("git", "add", ".").Run()
	if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
		t.Fatalf("initial commit failed: %v", err)
	}

	// Create database and import base state
	testDBPath := filepath.Join(beadsDir, "beads.db")
	testStore, err := sqlite.New(ctx, testDBPath)
	if err != nil {
		t.Fatalf("failed to create test store: %v", err)
	}
	defer testStore.Close()

	// Set issue_prefix
	if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
		t.Fatalf("failed to set issue_prefix: %v", err)
	}

	// Load base state for 3-way merge
	baseIssues, err := loadIssuesFromJSONL(jsonlPath)
	if err != nil {
		t.Fatalf("loadIssuesFromJSONL (base) failed: %v", err)
	}

	// Create local issue (modified at 2025-01-02)
	localTime := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
	localIssueObj := &types.Issue{
		ID:        "bd-1",
		Title:     "Local Edit",
		Status:    types.StatusOpen,
		IssueType: types.TypeTask,
		Priority:  2,
		CreatedAt: baseTime,
		UpdatedAt: localTime,
	}
	localIssues := []*types.Issue{localIssueObj}

	// Simulate "remote" edit: change title in JSONL (modified at 2025-01-03 - later)
	remoteIssue := `{"id":"bd-1","title":"Remote Edit","status":"open","issue_type":"task","priority":2,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-03T00:00:00Z"}`
	if err := os.WriteFile(jsonlPath, []byte(remoteIssue+"\n"), 0644); err != nil {
		t.Fatalf("write remote JSONL failed: %v", err)
	}

	remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
	if err != nil {
		t.Fatalf("loadIssuesFromJSONL (remote) failed: %v", err)
	}

	if len(remoteIssues) != 1 {
		t.Fatalf("expected 1 remote issue, got %d", len(remoteIssues))
	}

	// 3-way merge with base state
	mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)

	// Verify merge result
	if len(mergeResult.Merged) != 1 {
		t.Fatalf("expected 1 merged issue, got %d", len(mergeResult.Merged))
	}

	// LWW: Remote wins because it has later updated_at (2025-01-03 > 2025-01-02)
	if mergeResult.Merged[0].Title != "Remote Edit" {
		t.Errorf("expected merged title 'Remote Edit' (remote wins LWW), got '%s'", mergeResult.Merged[0].Title)
	}

	// Verify strategy: should be "merged" (conflict resolved by LWW)
	if mergeResult.Strategy["bd-1"] != StrategyMerged {
		t.Errorf("expected strategy '%s' for bd-1, got '%s'", StrategyMerged, mergeResult.Strategy["bd-1"])
	}

	// Verify 1 conflict was detected and resolved
	if mergeResult.Conflicts != 1 {
		t.Errorf("expected 1 conflict (both sides modified), got %d", mergeResult.Conflicts)
	}

	t.Log("TestConcurrentEdit: 3-way merge with LWW resolution validated")
}

// TestConcurrentSyncBlocked tests that concurrent syncs are blocked by file lock.
// This validates the P0 fix for preventing data corruption when running bd sync
// from multiple terminals simultaneously.
func TestConcurrentSyncBlocked(t *testing.T) {
	ctx := context.Background()
	tmpDir := t.TempDir()
	t.Chdir(tmpDir)

	// Setup: Initialize git repo
	if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
		t.Fatalf("git init failed: %v", err)
	}
	_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
	_ = exec.Command("git", "config", "user.name", "Test User").Run()

	// Setup: Create beads directory with JSONL
	beadsDir := filepath.Join(tmpDir, ".beads")
	if err := os.MkdirAll(beadsDir, 0755); err != nil {
		t.Fatalf("mkdir failed: %v", err)
	}
	jsonlPath := filepath.Join(beadsDir, "issues.jsonl")

	// Create initial JSONL
	if err := os.WriteFile(jsonlPath, []byte(`{"id":"bd-1","title":"Test"}`+"\n"), 0644); err != nil {
		t.Fatalf("write JSONL failed: %v", err)
	}

	// Initial commit
	_ = exec.Command("git", "add", ".").Run()
	if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
		t.Fatalf("initial commit failed: %v", err)
	}

	// Create database
	testDBPath := filepath.Join(beadsDir, "beads.db")
	testStore, err := sqlite.New(ctx, testDBPath)
	if err != nil {
		t.Fatalf("failed to create test store: %v", err)
	}
	defer testStore.Close()

	// Set issue_prefix
	if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
		t.Fatalf("failed to set issue_prefix: %v", err)
	}

	// Simulate another sync holding the lock
	lockPath := filepath.Join(beadsDir, ".sync.lock")
	lock := flock.New(lockPath)
	locked, err := lock.TryLock()
	if err != nil {
		t.Fatalf("failed to acquire lock: %v", err)
	}
	if !locked {
		t.Fatal("expected to acquire lock")
	}

	// Now try to acquire the same lock (simulating concurrent sync)
	lock2 := flock.New(lockPath)
	locked2, err := lock2.TryLock()
	if err != nil {
		t.Fatalf("TryLock error: %v", err)
	}

	// Second lock attempt should fail (not block)
	if locked2 {
		lock2.Unlock()
		t.Error("expected second lock attempt to fail (concurrent sync should be blocked)")
	} else {
		t.Log("Concurrent sync correctly blocked by file lock")
	}

	// Release first lock
	if err := lock.Unlock(); err != nil {
		t.Fatalf("failed to unlock: %v", err)
	}

	// Now lock should be acquirable again
	lock3 := flock.New(lockPath)
	locked3, err := lock3.TryLock()
	if err != nil {
		t.Fatalf("TryLock error after unlock: %v", err)
	}
	if !locked3 {
		t.Error("expected lock to be acquirable after first sync completes")
	} else {
		lock3.Unlock()
		t.Log("Lock correctly acquirable after first sync completes")
	}
}
