package main

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"
	"github.com/steveyegge/beads/internal/beads"
	"github.com/steveyegge/beads/internal/config"
	"github.com/steveyegge/beads/internal/debug"
	"github.com/steveyegge/beads/internal/git"
	"github.com/steveyegge/beads/internal/syncbranch"
)

var syncCmd = &cobra.Command{
	Use:     "sync",
	GroupID: "sync",
	Short:   "Synchronize issues with git remote",
	Long: `Synchronize issues with git remote in a single operation:
1. Export pending changes to JSONL
2. Commit changes to git
3. Pull from remote (with conflict resolution)
4. Import updated JSONL
5. Push local commits to remote

This command wraps the entire git-based sync workflow for multi-device use.

Use --squash to accumulate changes without committing (reduces commit noise).
Use --flush-only to just export pending changes to JSONL (useful for pre-commit hooks).
Use --import-only to just import from JSONL (useful after git pull).
Use --status to show diff between sync branch and main branch.
Use --merge to merge the sync branch back to main branch.`,
	Run: func(cmd *cobra.Command, _ []string) {
		CheckReadonly("sync")
		ctx := rootCtx

		message, _ := cmd.Flags().GetString("message")
		dryRun, _ := cmd.Flags().GetBool("dry-run")
		noPush, _ := cmd.Flags().GetBool("no-push")
		noPull, _ := cmd.Flags().GetBool("no-pull")
		renameOnImport, _ := cmd.Flags().GetBool("rename-on-import")
		flushOnly, _ := cmd.Flags().GetBool("flush-only")
		importOnly, _ := cmd.Flags().GetBool("import-only")
		status, _ := cmd.Flags().GetBool("status")
		merge, _ := cmd.Flags().GetBool("merge")
		fromMain, _ := cmd.Flags().GetBool("from-main")
		noGitHistory, _ := cmd.Flags().GetBool("no-git-history")
		squash, _ := cmd.Flags().GetBool("squash")
		checkIntegrity, _ := cmd.Flags().GetBool("check")

		// If --no-push not explicitly set, check no-push config
		if !cmd.Flags().Changed("no-push") {
			noPush = config.GetBool("no-push")
		}

		// Force direct mode for sync operations.
		// This prevents stale daemon SQLite connections from corrupting exports.
		// If the daemon was running but its database file was deleted and recreated
		// (e.g., during recovery), the daemon's SQLite connection points to the old
		// (deleted) file, causing export to return incomplete/corrupt data.
		// Using direct mode ensures we always read from the current database file.
		if daemonClient != nil {
			debug.Logf("sync: forcing direct mode for consistency")
			_ = daemonClient.Close()
			daemonClient = nil
		}

		// Resolve noGitHistory based on fromMain (fixes #417)
		noGitHistory = resolveNoGitHistoryForFromMain(fromMain, noGitHistory)

		// Find JSONL path
		jsonlPath := findJSONLPath()
		if jsonlPath == "" {
			FatalError("not in a bd workspace (no .beads directory found)")
		}

		// If status mode, show diff between sync branch and main
		if status {
			if err := showSyncStatus(ctx); err != nil {
				FatalError("%v", err)
			}
			return
		}

		// If check mode, run pre-sync integrity checks
		if checkIntegrity {
			showSyncIntegrityCheck(ctx, jsonlPath)
			return
		}

		// If merge mode, merge sync branch to main
		if merge {
			if err := mergeSyncBranch(ctx, dryRun); err != nil {
				FatalError("%v", err)
			}
			return
		}

		// If from-main mode, one-way sync from main branch (gt-ick9: ephemeral branch support)
		if fromMain {
			if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, noGitHistory); err != nil {
				FatalError("%v", err)
			}
			return
		}

		// If import-only mode, just import and exit
		if importOnly {
			if dryRun {
				fmt.Println("→ [DRY RUN] Would import from JSONL")
			} else {
				fmt.Println("→ Importing from JSONL...")
				if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
					FatalError("importing: %v", err)
				}
				fmt.Println("✓ Import complete")
			}
			return
		}

		// If flush-only mode, just export and exit
		if flushOnly {
			if dryRun {
				fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
			} else {
				if err := exportToJSONL(ctx, jsonlPath); err != nil {
					FatalError("exporting: %v", err)
				}
			}
			return
		}

		// If squash mode, export to JSONL but skip git operations
		// This accumulates changes for a single commit later
		if squash {
			if dryRun {
				fmt.Println("→ [DRY RUN] Would export pending changes to JSONL (squash mode)")
			} else {
				fmt.Println("→ Exporting pending changes to JSONL (squash mode)...")
				if err := exportToJSONL(ctx, jsonlPath); err != nil {
					FatalError("exporting: %v", err)
				}
				fmt.Println("✓ Changes accumulated in JSONL")
				fmt.Println("  Run 'bd sync' (without --squash) to commit all accumulated changes")
			}
			return
		}

		// Check if we're in a git repository
		if !isGitRepo() {
			FatalErrorWithHint("not in a git repository", "run 'git init' to initialize a repository")
		}

		// Preflight: check for merge/rebase in progress
		if inMerge, err := gitHasUnmergedPaths(); err != nil {
			FatalError("checking git state: %v", err)
		} else if inMerge {
			FatalErrorWithHint("unmerged paths or merge in progress", "resolve conflicts, run 'bd import' if needed, then 'bd sync' again")
		}

		// GH#638: Check sync.branch BEFORE upstream check
		// When sync.branch is configured, we should use worktree-based sync even if
		// the current branch has no upstream (e.g., detached HEAD in jj, git worktrees)
		var hasSyncBranchConfig bool
		if err := ensureStoreActive(); err == nil && store != nil {
			if syncBranch, _ := syncbranch.Get(ctx, store); syncBranch != "" {
				hasSyncBranchConfig = true
			}
		}

		// Preflight: check for upstream tracking
		// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
		// GH#638: Skip this fallback if sync.branch is explicitly configured
		if !noPull && !gitHasUpstream() && !hasSyncBranchConfig {
			if hasGitRemote(ctx) {
				// Remote exists but no upstream - use from-main mode
				fmt.Println("→ No upstream configured, using --from-main mode")
				// Force noGitHistory=true for auto-detected from-main mode (fixes #417)
				if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, true); err != nil {
					FatalError("%v", err)
				}
				return
			}
			// If no remote at all, gitPull/gitPush will gracefully skip
		}

		// Step 1: Export pending changes (but check for stale DB first)
		skipExport := false // Track if we should skip export due to ZFC import
		if dryRun {
			fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
		} else {
			// ZFC safety check: if DB significantly diverges from JSONL,
			// force import first to sync with JSONL source of truth.
			// After import, skip export to prevent overwriting JSONL (JSONL is source of truth).
			//
			// Added REVERSE ZFC check - if JSONL has MORE issues than DB,
			// this indicates the DB is stale and exporting would cause data loss.
			// This catches the case where a fresh/stale clone tries to export an
			// empty or outdated database over a JSONL with many issues.
			if err := ensureStoreActive(); err == nil && store != nil {
				dbCount, err := countDBIssuesFast(ctx, store)
				if err == nil {
					jsonlCount, err := countIssuesInJSONL(jsonlPath)
					if err == nil && jsonlCount > 0 {
						// Case 1: DB has significantly more issues than JSONL
						// (original ZFC check - DB is ahead of JSONL)
						if dbCount > jsonlCount {
							divergence := float64(dbCount-jsonlCount) / float64(jsonlCount)
							if divergence > 0.5 { // >50% more issues in DB than JSONL
								fmt.Printf("→ DB has %d issues but JSONL has %d (stale JSONL detected)\n", dbCount, jsonlCount)
								fmt.Println("→ Importing JSONL first (ZFC)...")
								if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
									FatalError("importing (ZFC): %v", err)
								}
								// Skip export after ZFC import - JSONL is source of truth
								skipExport = true
								fmt.Println("→ Skipping export (JSONL is source of truth after ZFC import)")
							}
						}

						// Case 2: JSONL has significantly more issues than DB
						// This is the DANGEROUS case - exporting would lose issues!
						// A stale/empty DB exporting over a populated JSONL causes data loss.
						if jsonlCount > dbCount && !skipExport {
							divergence := float64(jsonlCount-dbCount) / float64(jsonlCount)
							// Use stricter threshold for this dangerous case:
							// - Any loss > 20% is suspicious
							// - Complete loss (DB empty) is always blocked
							if dbCount == 0 || divergence > 0.2 {
								fmt.Printf("→ JSONL has %d issues but DB has only %d (stale DB detected)\n", jsonlCount, dbCount)
								fmt.Println("→ Importing JSONL first to prevent data loss...")
								if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
									FatalError("importing (reverse ZFC): %v", err)
								}
								// Skip export after import - JSONL is source of truth
								skipExport = true
								fmt.Println("→ Skipping export (JSONL is source of truth after reverse ZFC import)")
							}
						}
					}
				}

				// Case 3: JSONL content differs from DB (hash mismatch)
				// This catches the case where counts match but STATUS/content differs.
				// A stale DB exporting wrong status values over correct JSONL values
				// causes corruption that the 3-way merge propagates.
				//
				// Example: Remote has status=open, stale DB has status=closed (count=5 both)
				// Without this check: export writes status=closed → git merge keeps it → corruption
				// With this check: detect hash mismatch → import first → get correct status
				//
				// Note: Auto-import in autoflush.go also checks for hash changes during store
				// initialization, so this check may be redundant in most cases. However, it
				// provides defense-in-depth for cases where auto-import is disabled or bypassed.
				if !skipExport {
					repoKey := getRepoKeyForPath(jsonlPath)
					if hasJSONLChanged(ctx, store, jsonlPath, repoKey) {
						fmt.Println("→ JSONL content differs from last sync")
						fmt.Println("→ Importing JSONL first to prevent stale DB from overwriting changes...")
						if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
							FatalError("importing (hash mismatch): %v", err)
						}
						// Don't skip export - we still want to export any remaining local dirty issues
						// The import updated DB with JSONL content, and export will write merged state
						fmt.Println("→ Import complete, continuing with export of merged state")
					}
				}
			}

			if !skipExport {
				// Pre-export integrity checks
				if err := ensureStoreActive(); err == nil && store != nil {
					if err := validatePreExport(ctx, store, jsonlPath); err != nil {
						FatalError("pre-export validation failed: %v", err)
					}
					if err := checkDuplicateIDs(ctx, store); err != nil {
						FatalError("database corruption detected: %v", err)
					}
					if orphaned, err := checkOrphanedDeps(ctx, store); err != nil {
						fmt.Fprintf(os.Stderr, "Warning: orphaned dependency check failed: %v\n", err)
					} else if len(orphaned) > 0 {
						fmt.Fprintf(os.Stderr, "Warning: found %d orphaned dependencies: %v\n", len(orphaned), orphaned)
					}
				}

				fmt.Println("→ Exporting pending changes to JSONL...")
				if err := exportToJSONL(ctx, jsonlPath); err != nil {
					FatalError("exporting: %v", err)
				}
			}

			// Capture left snapshot (pre-pull state) for 3-way merge
			// This is mandatory for deletion tracking integrity
			if err := captureLeftSnapshot(jsonlPath); err != nil {
				FatalError("failed to capture snapshot (required for deletion tracking): %v", err)
			}
		}

		// Check if BEADS_DIR points to an external repository (dand-oss fix)
		// If so, use direct git operations instead of worktree-based sync
		beadsDir := filepath.Dir(jsonlPath)
		isExternal := isExternalBeadsDir(ctx, beadsDir)

		if isExternal {
			// External BEADS_DIR: commit/pull directly to the beads repo
			fmt.Println("→ External BEADS_DIR detected, using direct commit...")

			// Check for changes in the external beads repo
			externalRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
			if err != nil {
				FatalError("%v", err)
			}

			// Check if there are changes to commit
			relBeadsDir, _ := filepath.Rel(externalRepoRoot, beadsDir)
			statusCmd := exec.CommandContext(ctx, "git", "-C", externalRepoRoot, "status", "--porcelain", relBeadsDir)
			statusOutput, _ := statusCmd.Output()
			externalHasChanges := len(strings.TrimSpace(string(statusOutput))) > 0

			if externalHasChanges {
				if dryRun {
					fmt.Printf("→ [DRY RUN] Would commit changes to external beads repo at %s\n", externalRepoRoot)
				} else {
					committed, err := commitToExternalBeadsRepo(ctx, beadsDir, message, !noPush)
					if err != nil {
						FatalError("%v", err)
					}
					if committed {
						if !noPush {
							fmt.Println("✓ Committed and pushed to external beads repo")
						} else {
							fmt.Println("✓ Committed to external beads repo")
						}
					}
				}
			} else {
				fmt.Println("→ No changes to commit in external beads repo")
			}

			if !noPull {
				if dryRun {
					fmt.Printf("→ [DRY RUN] Would pull from external beads repo at %s\n", externalRepoRoot)
				} else {
					fmt.Println("→ Pulling from external beads repo...")
					if err := pullFromExternalBeadsRepo(ctx, beadsDir); err != nil {
						FatalError("pulling: %v", err)
					}
					fmt.Println("✓ Pulled from external beads repo")

					// Re-import after pull to update local database
					fmt.Println("→ Importing JSONL...")
					if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
						FatalError("importing: %v", err)
					}
				}
			}

			// Clear sync state on successful sync (daemon backoff/hints)
			_ = ClearSyncState(beadsDir)

			fmt.Println("\n✓ Sync complete")
			return
		}

		// Check if sync.branch is configured for worktree-based sync
		// This allows committing to a separate branch without changing the user's working directory
		var syncBranchName string
		var repoRoot string
		var useSyncBranch bool
		var onSyncBranch bool // GH#519: track if we're on the sync branch
		if err := ensureStoreActive(); err == nil && store != nil {
			syncBranchName, _ = syncbranch.Get(ctx, store)
			if syncBranchName != "" && syncbranch.HasGitRemote(ctx) {
				repoRoot, err = syncbranch.GetRepoRoot(ctx)
				if err != nil {
					fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err)
					fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n")
				} else {
					// GH#519: Check if current branch is the sync branch
					// If so, commit directly instead of using worktree (which would fail)
					currentBranch, _ := getCurrentBranch(ctx)
					if currentBranch == syncBranchName {
						onSyncBranch = true
						// Don't use worktree - commit directly to current branch
						useSyncBranch = false
					} else {
						useSyncBranch = true
					}
				}
			}
		}

		// Step 2: Check if there are changes to commit (check entire .beads/ directory)
		hasChanges, err := gitHasBeadsChanges(ctx)
		if err != nil {
			FatalError("checking git status: %v", err)
		}

		// Track if we already pushed via worktree (to skip Step 5)
		pushedViaSyncBranch := false

		if hasChanges {
			if dryRun {
				if useSyncBranch {
					fmt.Printf("→ [DRY RUN] Would commit changes to sync branch '%s' via worktree\n", syncBranchName)
				} else if onSyncBranch {
					// GH#519: on sync branch, commit directly
					fmt.Printf("→ [DRY RUN] Would commit changes directly to sync branch '%s'\n", syncBranchName)
				} else {
					fmt.Println("→ [DRY RUN] Would commit changes to git")
				}
			} else if useSyncBranch {
				// Use worktree to commit to sync branch
				fmt.Printf("→ Committing changes to sync branch '%s'...\n", syncBranchName)
				result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush)
				if err != nil {
					FatalError("committing to sync branch: %v", err)
				}
				if result.Committed {
					fmt.Printf("✓ Committed to %s\n", syncBranchName)
					if result.Pushed {
						fmt.Printf("✓ Pushed %s to remote\n", syncBranchName)
						pushedViaSyncBranch = true
					}
				}
			} else {
				// Regular commit to current branch
				// GH#519: if on sync branch, show appropriate message
				if onSyncBranch {
					fmt.Printf("→ Committing changes directly to sync branch '%s'...\n", syncBranchName)
				} else {
					fmt.Println("→ Committing changes to git...")
				}
				if err := gitCommitBeadsDir(ctx, message); err != nil {
					FatalError("committing: %v", err)
				}
			}
		} else {
			fmt.Println("→ No changes to commit")
		}

		// Step 3: Pull from remote
		// Note: If no upstream, we already handled it above with --from-main mode
		if !noPull {
			if dryRun {
				if useSyncBranch {
					fmt.Printf("→ [DRY RUN] Would pull from sync branch '%s' via worktree\n", syncBranchName)
				} else if onSyncBranch {
					// GH#519: on sync branch, regular git pull
					fmt.Printf("→ [DRY RUN] Would pull directly on sync branch '%s'\n", syncBranchName)
				} else {
					fmt.Println("→ [DRY RUN] Would pull from remote")
				}
			} else {
				// Execute pull - either via sync branch worktree or regular git pull
				if useSyncBranch {
					// Pull from sync branch via worktree
					fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranchName)

					// Check if confirmation is required for mass deletion
					requireMassDeleteConfirmation := config.GetBool("sync.require_confirmation_on_mass_delete")

					pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush, requireMassDeleteConfirmation)
					if err != nil {
						FatalError("pulling from sync branch: %v", err)
					}
					if pullResult.Pulled {
						if pullResult.Merged {
							// Divergent histories were merged at content level
							fmt.Printf("✓ Merged divergent histories from %s\n", syncBranchName)

							// Print safety warnings from result
							for _, warning := range pullResult.SafetyWarnings {
								fmt.Fprintln(os.Stderr, warning)
							}

							// Handle safety check with confirmation requirement
							if pullResult.SafetyCheckTriggered && !pullResult.Pushed {
								// Don't duplicate SafetyCheckDetails - it's already in SafetyWarnings
								// Prompt for confirmation
								fmt.Fprintf(os.Stderr, "Push these changes to remote? [y/N]: ")

								var response string
								reader := bufio.NewReader(os.Stdin)
								response, _ = reader.ReadString('\n')
								response = strings.TrimSpace(strings.ToLower(response))

								if response == "y" || response == "yes" {
									fmt.Printf("→ Pushing to %s...\n", syncBranchName)
									if err := syncbranch.PushSyncBranch(ctx, repoRoot, syncBranchName); err != nil {
										FatalError("pushing to sync branch: %v", err)
									}
									fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName)
									pushedViaSyncBranch = true
								} else {
									fmt.Println("Push canceled. Run 'bd sync' again to retry.")
									fmt.Println("If this was unintended, use 'git reflog' on the sync branch to recover.")
								}
							} else if pullResult.Pushed {
								// Auto-push after merge
								fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName)
								pushedViaSyncBranch = true
							}
						} else if pullResult.FastForwarded {
							fmt.Printf("✓ Fast-forwarded from %s\n", syncBranchName)
						} else {
							fmt.Printf("✓ Pulled from %s\n", syncBranchName)
						}
					}
					// JSONL is already copied to main repo by PullFromSyncBranch
				} else {
					// Check merge driver configuration before pulling
					checkMergeDriverConfig()

					// GH#519: show appropriate message when on sync branch
					if onSyncBranch {
						fmt.Printf("→ Pulling from remote on sync branch '%s'...\n", syncBranchName)
					} else {
						fmt.Println("→ Pulling from remote...")
					}
					err := gitPull(ctx)
					if err != nil {
						// Check if it's a rebase conflict on beads.jsonl that we can auto-resolve
						if isInRebase() && hasJSONLConflict() {
							fmt.Println("→ Auto-resolving JSONL merge conflict...")

							// Export clean JSONL from DB (database is source of truth)
							if exportErr := exportToJSONL(ctx, jsonlPath); exportErr != nil {
								FatalErrorWithHint(fmt.Sprintf("failed to export for conflict resolution: %v", exportErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again")
							}

							// Mark conflict as resolved
							addCmd := exec.CommandContext(ctx, "git", "add", jsonlPath)
							if addErr := addCmd.Run(); addErr != nil {
								FatalErrorWithHint(fmt.Sprintf("failed to mark conflict resolved: %v", addErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again")
							}

							// Continue rebase
							if continueErr := runGitRebaseContinue(ctx); continueErr != nil {
								FatalErrorWithHint(fmt.Sprintf("failed to continue rebase: %v", continueErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again")
							}

							fmt.Println("✓ Auto-resolved JSONL conflict")
						} else {
							// Not an auto-resolvable conflict, fail with original error
							// Check if this looks like a merge driver failure
							errStr := err.Error()
							if strings.Contains(errStr, "merge driver") ||
								strings.Contains(errStr, "no such file or directory") ||
								strings.Contains(errStr, "MERGE DRIVER INVOKED") {
								fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n")
								fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n")
							}

							FatalErrorWithHint(fmt.Sprintf("pulling: %v", err), "resolve conflicts manually and run 'bd import' then 'bd sync' again")
						}
					}
				}

				// Import logic - shared for both sync branch and regular pull paths
				// Count issues before import for validation
				var beforeCount int
				if err := ensureStoreActive(); err == nil && store != nil {
					beforeCount, err = countDBIssues(ctx, store)
					if err != nil {
						fmt.Fprintf(os.Stderr, "Warning: failed to count issues before import: %v\n", err)
					}
				}

				// Step 3.5: Perform 3-way merge and prune deletions
				if err := ensureStoreActive(); err == nil && store != nil {
					if err := applyDeletionsFromMerge(ctx, store, jsonlPath); err != nil {
						FatalError("during 3-way merge: %v", err)
					}
				}

				// Step 4: Import updated JSONL after pull
				// Enable --protect-left-snapshot to prevent git-history-backfill from
				// tombstoning issues that were in our local export but got lost during merge
				fmt.Println("→ Importing updated JSONL...")
				if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory, true); err != nil {
					FatalError("importing: %v", err)
				}

				// Validate import didn't cause data loss
				if beforeCount > 0 {
					if err := ensureStoreActive(); err == nil && store != nil {
						afterCount, err := countDBIssues(ctx, store)
						if err != nil {
							fmt.Fprintf(os.Stderr, "Warning: failed to count issues after import: %v\n", err)
						} else {
							if err := validatePostImportWithExpectedDeletions(beforeCount, afterCount, 0, jsonlPath); err != nil {
								FatalError("post-import validation failed: %v", err)
							}
						}
					}
				}

				// Post-pull ZFC check: if skipExport was set by initial ZFC detection,
				// or if DB has more issues than JSONL, skip re-export.
				// This prevents resurrection of deleted issues when syncing stale clones.
				skipReexport := skipExport // Carry forward initial ZFC detection
				if !skipReexport {
					if err := ensureStoreActive(); err == nil && store != nil {
						dbCountPostImport, dbErr := countDBIssuesFast(ctx, store)
						jsonlCountPostPull, jsonlErr := countIssuesInJSONL(jsonlPath)
						if dbErr == nil && jsonlErr == nil && jsonlCountPostPull > 0 {
							// Skip re-export if DB has more issues than JSONL (any amount)
							if dbCountPostImport > jsonlCountPostPull {
								fmt.Printf("→ DB (%d) has more issues than JSONL (%d) after pull\n",
									dbCountPostImport, jsonlCountPostPull)
								fmt.Println("→ Trusting JSONL as source of truth (skipping re-export)")
								fmt.Println("  Hint: Run 'bd import --delete-missing' to fully sync DB with JSONL")
								skipReexport = true
							}
						}
					}
				}

				// Step 4.5: Check if DB needs re-export (only if DB differs from JSONL)
				// This prevents the infinite loop: import → export → commit → dirty again
				if !skipReexport {
					if err := ensureStoreActive(); err == nil && store != nil {
						needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
						if err != nil {
							fmt.Fprintf(os.Stderr, "Warning: failed to check if export needed: %v\n", err)
							// Conservative: assume export needed
							needsExport = true
						}

						if needsExport {
							fmt.Println("→ Re-exporting after import to sync DB changes...")
							if err := exportToJSONL(ctx, jsonlPath); err != nil {
								FatalError("re-exporting after import: %v", err)
							}

							// Step 4.6: Commit the re-export if it created changes
							hasPostImportChanges, err := gitHasBeadsChanges(ctx)
							if err != nil {
								FatalError("checking git status after re-export: %v", err)
							}
							if hasPostImportChanges {
								fmt.Println("→ Committing DB changes from import...")
								if useSyncBranch {
									// Commit to sync branch via worktree
									result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush)
									if err != nil {
										FatalError("committing to sync branch: %v", err)
									}
									if result.Pushed {
										pushedViaSyncBranch = true
									}
								} else {
									if err := gitCommitBeadsDir(ctx, "bd sync: apply DB changes after import"); err != nil {
										FatalError("committing post-import changes: %v", err)
									}
								}
								hasChanges = true // Mark that we have changes to push
							}
						} else {
							fmt.Println("→ DB and JSONL in sync, skipping re-export")
						}
					}
				}

				// Update base snapshot after successful import
				if err := updateBaseSnapshot(jsonlPath); err != nil {
					fmt.Fprintf(os.Stderr, "Warning: failed to update base snapshot: %v\n", err)
				}
			}
		}

		// Step 5: Push to remote (skip if using sync branch - all pushes go via worktree)
		// When sync.branch is configured, we don't push the main branch at all.
		// The sync branch worktree handles all pushes.
		if !noPush && hasChanges && !pushedViaSyncBranch && !useSyncBranch {
			if dryRun {
				fmt.Println("→ [DRY RUN] Would push to remote")
			} else {
				fmt.Println("→ Pushing to remote...")
				if err := gitPush(ctx); err != nil {
					FatalErrorWithHint(fmt.Sprintf("pushing: %v", err), "pull may have brought new changes, run 'bd sync' again")
				}
			}
		}

		if dryRun {
			fmt.Println("\n✓ Dry run complete (no changes made)")
		} else {
			// Clean up temporary snapshot files after successful sync
			// This runs regardless of whether pull was performed
			sm := NewSnapshotManager(jsonlPath)
			if err := sm.Cleanup(); err != nil {
				fmt.Fprintf(os.Stderr, "Warning: failed to clean up snapshots: %v\n", err)
			}

			// When using sync.branch, restore .beads/ from current branch to keep
			// working directory clean. The actual beads data lives on the sync branch,
			// and the main branch's .beads/ should match what's committed there.
			// This prevents "modified .beads/" showing in git status after sync.
			if useSyncBranch {
				if err := restoreBeadsDirFromBranch(ctx); err != nil {
					// Non-fatal - just means git status will show modified files
					debug.Logf("sync: failed to restore .beads/ from branch: %v", err)
				}
				// Skip final flush in PersistentPostRun - we've already exported to sync branch
				// and restored the working directory to match the current branch
				skipFinalFlush = true
			}

			// Clear sync state on successful sync (daemon backoff/hints)
			if bd := beads.FindBeadsDir(); bd != "" {
				_ = ClearSyncState(bd)
			}

			fmt.Println("\n✓ Sync complete")
		}
	},
}

func init() {
	syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
	syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
	syncCmd.Flags().Bool("no-push", false, "Skip pushing to remote")
	syncCmd.Flags().Bool("no-pull", false, "Skip pulling from remote")
	syncCmd.Flags().Bool("rename-on-import", false, "Rename imported issues to match database prefix (updates all references)")
	syncCmd.Flags().Bool("flush-only", false, "Only export pending changes to JSONL (skip git operations)")
	syncCmd.Flags().Bool("squash", false, "Accumulate changes in JSONL without committing (run 'bd sync' later to commit all)")
	syncCmd.Flags().Bool("import-only", false, "Only import from JSONL (skip git operations, useful after git pull)")
	syncCmd.Flags().Bool("status", false, "Show diff between sync branch and main branch")
	syncCmd.Flags().Bool("merge", false, "Merge sync branch back to main branch")
	syncCmd.Flags().Bool("from-main", false, "One-way sync from main branch (for ephemeral branches without upstream)")
	syncCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)")
	syncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output sync statistics in JSON format")
	syncCmd.Flags().Bool("check", false, "Pre-sync integrity check: detect forced pushes, prefix mismatches, and orphaned issues")
	rootCmd.AddCommand(syncCmd)
}

// isGitRepo checks if the current directory is in a git repository
func isGitRepo() bool {
	cmd := exec.Command("git", "rev-parse", "--git-dir")
	return cmd.Run() == nil
}

// gitHasUnmergedPaths checks for unmerged paths or merge in progress
func gitHasUnmergedPaths() (bool, error) {
	cmd := exec.Command("git", "status", "--porcelain")
	out, err := cmd.Output()
	if err != nil {
		return false, fmt.Errorf("git status failed: %w", err)
	}

	// Check for unmerged status codes (DD, AU, UD, UA, DU, AA, UU)
	for _, line := range strings.Split(string(out), "\n") {
		if len(line) >= 2 {
			s := line[:2]
			if s == "DD" || s == "AU" || s == "UD" || s == "UA" || s == "DU" || s == "AA" || s == "UU" {
				return true, nil
			}
		}
	}

	// Check if MERGE_HEAD exists (merge in progress)
	if exec.Command("git", "rev-parse", "-q", "--verify", "MERGE_HEAD").Run() == nil {
		return true, nil
	}

	return false, nil
}

// gitHasUpstream checks if the current branch has an upstream configured
// Uses git config directly for compatibility with Git for Windows
func gitHasUpstream() bool {
	// Get current branch name
	branchCmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
	branchOutput, err := branchCmd.Output()
	if err != nil {
		return false
	}
	branch := strings.TrimSpace(string(branchOutput))

	// Check if remote and merge refs are configured
	remoteCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
	mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch))

	remoteErr := remoteCmd.Run()
	mergeErr := mergeCmd.Run()

	return remoteErr == nil && mergeErr == nil
}

// gitHasChanges checks if the specified file has uncommitted changes
func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
	cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", filePath)
	output, err := cmd.Output()
	if err != nil {
		return false, fmt.Errorf("git status failed: %w", err)
	}
	return len(strings.TrimSpace(string(output))) > 0, nil
}

// getRepoRootForWorktree returns the main repository root for running git commands
// This is always the main repository root, never the worktree root
func getRepoRootForWorktree(_ context.Context) string {
	repoRoot, err := git.GetMainRepoRoot()
	if err != nil {
		// Fallback to current directory if GetMainRepoRoot fails
		return "."
	}
	return repoRoot
}

// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
	// Get the absolute path to .beads directory
	beadsDir := beads.FindBeadsDir()
	if beadsDir == "" {
		return false, fmt.Errorf("no .beads directory found")
	}

	// Get the repository root (handles worktrees properly)
	repoRoot := getRepoRootForWorktree(ctx)
	if repoRoot == "" {
		return false, fmt.Errorf("cannot determine repository root")
	}

	// Compute relative path from repo root to .beads
	relPath, err := filepath.Rel(repoRoot, beadsDir)
	if err != nil {
		// Fall back to absolute path if relative path fails
		statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
		statusOutput, err := statusCmd.Output()
		if err != nil {
			return false, fmt.Errorf("git status failed: %w", err)
		}
		return len(strings.TrimSpace(string(statusOutput))) > 0, nil
	}

	// Run git status with relative path from repo root
	statusCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "status", "--porcelain", relPath)
	statusOutput, err := statusCmd.Output()
	if err != nil {
		return false, fmt.Errorf("git status failed: %w", err)
	}
	return len(strings.TrimSpace(string(statusOutput))) > 0, nil
}

// buildGitCommitArgs returns git commit args with config-based author and signing options (GH#600)
// This allows users to configure a separate author and disable GPG signing for beads commits.
func buildGitCommitArgs(repoRoot, message string, extraArgs ...string) []string {
	args := []string{"-C", repoRoot, "commit"}

	// Add --author if configured
	if author := config.GetString("git.author"); author != "" {
		args = append(args, "--author", author)
	}

	// Add --no-gpg-sign if configured
	if config.GetBool("git.no-gpg-sign") {
		args = append(args, "--no-gpg-sign")
	}

	// Add message
	args = append(args, "-m", message)

	// Add any extra args (like -- pathspec)
	args = append(args, extraArgs...)

	return args
}

// gitCommit commits the specified file (worktree-aware)
func gitCommit(ctx context.Context, filePath string, message string) error {
	// Get the repository root (handles worktrees properly)
	repoRoot := getRepoRootForWorktree(ctx)
	if repoRoot == "" {
		return fmt.Errorf("cannot determine repository root")
	}

	// Make file path relative to repo root for git operations
	relPath, err := filepath.Rel(repoRoot, filePath)
	if err != nil {
		relPath = filePath // Fall back to absolute path
	}

	// Stage the file from repo root context
	addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relPath)
	if err := addCmd.Run(); err != nil {
		return fmt.Errorf("git add failed: %w", err)
	}

	// Generate message if not provided
	if message == "" {
		message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
	}

	// Commit from repo root context with config-based author and signing options
	// Use pathspec to commit ONLY this file
	// This prevents accidentally committing other staged files
	commitArgs := buildGitCommitArgs(repoRoot, message, "--", relPath)
	commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
	output, err := commitCmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git commit failed: %w\n%s", err, output)
	}

	return nil
}

// gitCommitBeadsDir stages and commits only sync-related files in .beads/
// This ensures bd sync doesn't accidentally commit other staged files.
// Only stages specific sync files (issues.jsonl, deletions.jsonl, metadata.json)
// to avoid staging gitignored snapshot files that may be tracked.
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
func gitCommitBeadsDir(ctx context.Context, message string) error {
	beadsDir := beads.FindBeadsDir()
	if beadsDir == "" {
		return fmt.Errorf("no .beads directory found")
	}

	// Get the repository root (handles worktrees properly)
	repoRoot := getRepoRootForWorktree(ctx)
	if repoRoot == "" {
		return fmt.Errorf("cannot determine repository root")
	}

	// Stage only the specific sync-related files
	// This avoids staging gitignored snapshot files (beads.*.jsonl, *.meta.json)
	// that may still be tracked from before they were added to .gitignore
	syncFiles := []string{
		filepath.Join(beadsDir, "issues.jsonl"),
		filepath.Join(beadsDir, "deletions.jsonl"),
		filepath.Join(beadsDir, "interactions.jsonl"),
		filepath.Join(beadsDir, "metadata.json"),
	}

	// Only add files that exist
	var filesToAdd []string
	for _, f := range syncFiles {
		if _, err := os.Stat(f); err == nil {
			// Convert to relative path from repo root for git operations
			relPath, err := filepath.Rel(repoRoot, f)
			if err != nil {
				relPath = f // Fall back to absolute path if relative fails
			}
			filesToAdd = append(filesToAdd, relPath)
		}
	}

	if len(filesToAdd) == 0 {
		return fmt.Errorf("no sync files found to commit")
	}

	// Stage only the sync files from repo root context (worktree-aware)
	args := append([]string{"-C", repoRoot, "add"}, filesToAdd...)
	addCmd := exec.CommandContext(ctx, "git", args...)
	if err := addCmd.Run(); err != nil {
		return fmt.Errorf("git add failed: %w", err)
	}

	// Generate message if not provided
	if message == "" {
		message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
	}

	// Commit only .beads/ files using -- pathspec
	// This prevents accidentally committing other staged files that the user
	// may have staged but wasn't ready to commit yet.
	// Convert beadsDir to relative path for git commit (worktree-aware)
	relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
	if err != nil {
		relBeadsDir = beadsDir // Fall back to absolute path if relative fails
	}

	// Use config-based author and signing options with pathspec
	commitArgs := buildGitCommitArgs(repoRoot, message, "--", relBeadsDir)
	commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
	output, err := commitCmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git commit failed: %w\n%s", err, output)
	}

	return nil
}

// hasGitRemote checks if a git remote exists in the repository
func hasGitRemote(ctx context.Context) bool {
	cmd := exec.CommandContext(ctx, "git", "remote")
	output, err := cmd.Output()
	if err != nil {
		return false
	}
	return len(strings.TrimSpace(string(output))) > 0
}

// isInRebase checks if we're currently in a git rebase state
func isInRebase() bool {
	// Get actual git directory (handles worktrees)
	gitDir, err := git.GetGitDir()
	if err != nil {
		return false
	}

	// Check for rebase-merge directory (interactive rebase)
	rebaseMergePath := filepath.Join(gitDir, "rebase-merge")
	if _, err := os.Stat(rebaseMergePath); err == nil {
		return true
	}
	// Check for rebase-apply directory (non-interactive rebase)
	rebaseApplyPath := filepath.Join(gitDir, "rebase-apply")
	if _, err := os.Stat(rebaseApplyPath); err == nil {
		return true
	}
	return false
}

// hasJSONLConflict checks if the beads JSONL file has a merge conflict
// Returns true only if the JSONL file (issues.jsonl or beads.jsonl) is the only file in conflict
func hasJSONLConflict() bool {
	cmd := exec.Command("git", "status", "--porcelain")
	out, err := cmd.Output()
	if err != nil {
		return false
	}

	var hasJSONLConflict bool
	var hasOtherConflict bool

	for _, line := range strings.Split(string(out), "\n") {
		if len(line) < 3 {
			continue
		}

		// Check for unmerged status codes (UU = both modified, AA = both added, etc.)
		status := line[:2]
		if status == "UU" || status == "AA" || status == "DD" ||
			status == "AU" || status == "UA" || status == "DU" || status == "UD" {
			filepath := strings.TrimSpace(line[3:])

			// Check for beads JSONL files (issues.jsonl or beads.jsonl in .beads/)
			if strings.HasSuffix(filepath, "issues.jsonl") || strings.HasSuffix(filepath, "beads.jsonl") {
				hasJSONLConflict = true
			} else {
				hasOtherConflict = true
			}
		}
	}

	// Only return true if ONLY the JSONL file has a conflict
	return hasJSONLConflict && !hasOtherConflict
}

// runGitRebaseContinue continues a rebase after resolving conflicts
func runGitRebaseContinue(ctx context.Context) error {
	cmd := exec.CommandContext(ctx, "git", "rebase", "--continue")
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output)
	}
	return nil
}

// gitPull pulls from the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func checkMergeDriverConfig() {
	// Get current merge driver configuration
	cmd := exec.Command("git", "config", "merge.beads.driver")
	output, err := cmd.Output()
	if err != nil {
		// No merge driver configured - this is OK, user may not need it
		return
	}

	currentConfig := strings.TrimSpace(string(output))

	// Check if using old incorrect placeholders
	if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") {
		fmt.Fprintf(os.Stderr, "\n⚠️  WARNING: Git merge driver is misconfigured!\n")
		fmt.Fprintf(os.Stderr, "   Current: %s\n", currentConfig)
		fmt.Fprintf(os.Stderr, "   Problem: Git only supports %%O (base), %%A (current), %%B (other)\n")
		fmt.Fprintf(os.Stderr, "            Using %%L/%%R will cause merge failures!\n")
		fmt.Fprintf(os.Stderr, "\n   Fix now: bd doctor --fix\n")
		fmt.Fprintf(os.Stderr, "   Or manually: git config merge.beads.driver \"bd merge %%A %%O %%A %%B\"\n\n")
	}
}

func gitPull(ctx context.Context) error {
	// Check if any remote exists (support local-only repos)
	if !hasGitRemote(ctx) {
		return nil // Gracefully skip - local-only mode
	}

	// Get current branch name
	// Use symbolic-ref to work in fresh repos without commits
	branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
	branchOutput, err := branchCmd.Output()
	if err != nil {
		return fmt.Errorf("failed to get current branch: %w", err)
	}
	branch := strings.TrimSpace(string(branchOutput))

	// Get remote name for current branch (usually "origin")
	remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
	remoteOutput, err := remoteCmd.Output()
	if err != nil {
		// If no remote configured, default to "origin"
		remoteOutput = []byte("origin\n")
	}
	remote := strings.TrimSpace(string(remoteOutput))

	// Pull with explicit remote and branch
	cmd := exec.CommandContext(ctx, "git", "pull", remote, branch)
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git pull failed: %w\n%s", err, output)
	}
	return nil
}

// gitPush pushes to the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func gitPush(ctx context.Context) error {
	// Check if any remote exists (support local-only repos)
	if !hasGitRemote(ctx) {
		return nil // Gracefully skip - local-only mode
	}

	cmd := exec.CommandContext(ctx, "git", "push")
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git push failed: %w\n%s", err, output)
	}
	return nil
}

// restoreBeadsDirFromBranch restores .beads/ directory from the current branch's committed state.
// This is used after sync when sync.branch is configured to keep the working directory clean.
// The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot.
func restoreBeadsDirFromBranch(ctx context.Context) error {
	beadsDir := beads.FindBeadsDir()
	if beadsDir == "" {
		return fmt.Errorf("no .beads directory found")
	}

	// Restore .beads/ from HEAD (current branch's committed state)
	// Using -- to ensure .beads/ is treated as a path, not a branch name
	cmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", beadsDir)
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git checkout failed: %w\n%s", err, output)
	}
	return nil
}

// getDefaultBranch returns the default branch name (main or master) for origin remote
// Checks remote HEAD first, then falls back to checking if main/master exist
func getDefaultBranch(ctx context.Context) string {
	return getDefaultBranchForRemote(ctx, "origin")
}

// getDefaultBranchForRemote returns the default branch name for a specific remote
// Checks remote HEAD first, then falls back to checking if main/master exist
func getDefaultBranchForRemote(ctx context.Context, remote string) string {
	// Try to get default branch from remote
	cmd := exec.CommandContext(ctx, "git", "symbolic-ref", fmt.Sprintf("refs/remotes/%s/HEAD", remote))
	output, err := cmd.Output()
	if err == nil {
		ref := strings.TrimSpace(string(output))
		// Extract branch name from refs/remotes/<remote>/main
		prefix := fmt.Sprintf("refs/remotes/%s/", remote)
		if strings.HasPrefix(ref, prefix) {
			return strings.TrimPrefix(ref, prefix)
		}
	}

	// Fallback: check if <remote>/main exists
	if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/main", remote)).Run() == nil {
		return "main"
	}

	// Fallback: check if <remote>/master exists
	if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)).Run() == nil {
		return "master"
	}

	// Default to main
	return "main"
}

// doSyncFromMain function moved to sync_import.go
// Export function moved to sync_export.go
// Sync branch functions moved to sync_branch.go
// Import functions moved to sync_import.go
// External beads dir functions moved to sync_branch.go
// Integrity check types and functions moved to sync_check.go
