/**
 * `tbd sync` - Synchronization commands.
 *
 * See: tbd-design.md §4.7 Sync Commands
 */

import { Command } from 'commander';

import { BaseCommand } from '../lib/base-command.js';
import {
  requireInit,
  SyncError,
  UnrelatedHistoriesError,
  classifySyncError,
} from '../lib/errors.js';
import { listIssues, writeIssue, type InvalidIssueFile } from '../../file/storage.js';
import {
  git,
  gitCommit,
  mergeBeadAcrossRefs,
  pushWithRetry,
  ensureWorktreeAttachedToBranch,
  checkRemoteBranchHealth,
  type ConflictEntry,
  type PushResult,
} from '../../file/git.js';
import { DATA_SYNC_DIR } from '../../lib/paths.js';
import { basename, join } from 'node:path';
import { access, readFile } from 'node:fs/promises';
import { writeFile } from 'atomically';
import {
  type SyncSummary,
  type SyncTallies,
  emptySummary,
  emptyTallies,
  hasTallies,
  formatSyncSummary,
  parseGitStatus,
  parseGitDiff,
} from '../../lib/sync-summary.js';
import { syncDocsWithDefaults, type SyncDocsResult } from '../../file/doc-sync.js';
import { ValidationError } from '../lib/errors.js';
import {
  loadIdMapping,
  saveIdMapping,
  mergeIdMappings,
  parseIdMappingFromYaml,
  reconcileMappings,
  resolveIdMappingConflicts,
} from '../../file/id-mapping.js';
import {
  saveToWorkspace,
  workspaceExists,
  importFromWorkspace,
  deleteWorkspace,
} from '../../file/workspace.js';
import { withDataSyncContext } from '../lib/data-context.js';

/**
 * List bead files in a data-sync directory that fail to parse. Used as a
 * post-merge guard so sync never reports success over a corrupted store (e.g. a
 * bead left holding git conflict markers). See issue #155.
 */
export async function findInvalidBeads(dataSyncDir: string): Promise<InvalidIssueFile[]> {
  const invalid: InvalidIssueFile[] = [];
  await listIssues(dataSyncDir, {
    warnOnInvalid: false,
    onInvalidIssue: (entry) => invalid.push(entry),
  });
  return invalid;
}

interface SyncOptions {
  push?: boolean;
  pull?: boolean;
  status?: boolean;
  force?: boolean;
  fix?: boolean;
  issues?: boolean;
  docs?: boolean;
  autoSave?: boolean; // Commander: --no-auto-save sets this to false (default: true)
  outbox?: boolean; // Commander: --no-outbox sets this to false (default: true)
}

interface SyncStatus {
  synced: boolean;
  localChanges: string[];
  remoteChanges: string[];
  syncBranch: string;
  remote: string;
  ahead: number;
  behind: number;
}

class SyncHandler extends BaseCommand {
  private dataSyncDir = '';
  private tbdRoot = '';
  private worktreePath = '';
  private syncBranch = '';

  async run(options: SyncOptions): Promise<void> {
    const tbdRoot = await requireInit();
    this.tbdRoot = tbdRoot;

    // Validate mutually exclusive options
    // --push/--pull only apply to issues (network operations)
    if ((options.push || options.pull) && options.docs) {
      throw new ValidationError('--push/--pull only work with issue sync, not --docs');
    }

    // Determine what to sync:
    // - If neither --issues nor --docs specified, sync both
    // - If --push or --pull specified (without --issues/--docs), sync only issues
    const hasExclusiveIssueFlag = Boolean(options.push) || Boolean(options.pull);
    const hasSelectiveFlag = Boolean(options.issues) || Boolean(options.docs);

    // Sync docs: explicit --docs, or default (no selective flags and no push/pull)
    const syncDocs = Boolean(options.docs) || (!hasSelectiveFlag && !hasExclusiveIssueFlag);
    // Sync issues: explicit --issues, push/pull flags, or default (no selective flags)
    const syncIssues = Boolean(options.issues) || hasExclusiveIssueFlag || !hasSelectiveFlag;

    // STEP 1: Sync docs first (fast, local operations)
    // This ensures docs are updated even if issue sync fails
    if (syncDocs) {
      await this.syncDocs(options.status);

      // If only doing docs, return after doc sync
      if (!syncIssues) {
        return;
      }
    }

    // STEP 2: Sync issues (network operations)
    await withDataSyncContext(
      tbdRoot,
      { lock: true },
      async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
        this.dataSyncDir = dataSyncDir;
        this.worktreePath = sharedPaths.sharedWorktreePath;
        this.syncBranch = config.sync.branch;

        const syncBranch = config.sync.branch;
        const remote = config.sync.remote;

        if (options.status) {
          await this.showIssueStatus(syncBranch, remote);
          return;
        }

        if (this.checkDryRun('Would sync repository', { syncBranch, remote })) {
          return;
        }

        if (repairedWorktreeStatus) {
          this.output.success('Worktree repaired successfully');
        }

        if (options.pull) {
          await this.pullChanges(syncBranch, remote);
        } else if (options.push) {
          await this.pushChanges(syncBranch, remote);
        } else {
          // Full sync: pull then push
          await this.fullSync(syncBranch, remote, {
            force: options.force,
            autoSave: options.autoSave,
            outbox: options.outbox,
          });
        }
      },
    );
  }

  /**
   * Sync docs from bundled sources and config.
   * This is a fast, local operation (no network required).
   */
  private async syncDocs(statusOnly?: boolean): Promise<SyncDocsResult> {
    if (statusOnly) {
      // Show status without making changes
      const result = await syncDocsWithDefaults(this.tbdRoot, { dryRun: true });
      this.showDocStatus(result);
      return result;
    }

    const spinner = this.output.spinner('Syncing docs...');
    const result = await syncDocsWithDefaults(this.tbdRoot);
    spinner.stop();

    // Report results
    this.showDocSyncResult(result);
    return result;
  }

  /**
   * Show doc sync status (what would change).
   */
  private showDocStatus(result: SyncDocsResult): void {
    const colors = this.output.getColors();
    const hasChanges =
      result.added.length > 0 ||
      result.updated.length > 0 ||
      result.removed.length > 0 ||
      result.pruned.length > 0;

    if (!hasChanges) {
      this.output.success('Docs up to date');
      return;
    }

    console.log(colors.bold('Docs:'));
    if (result.added.length > 0) {
      console.log(`  ${colors.success(`+${result.added.length}`)} new doc(s) available`);
    }
    if (result.updated.length > 0) {
      console.log(`  ${colors.warn(`~${result.updated.length}`)} doc(s) to update`);
    }
    if (result.removed.length > 0) {
      console.log(`  ${colors.error(`-${result.removed.length}`)} doc(s) to remove`);
    }
    if (result.pruned.length > 0) {
      console.log(`  ${colors.dim(`${result.pruned.length}`)} stale config entry/entries`);
    }
  }

  /**
   * Show doc sync result after sync.
   */
  private showDocSyncResult(result: SyncDocsResult): void {
    const hasChanges =
      result.added.length > 0 ||
      result.updated.length > 0 ||
      result.removed.length > 0 ||
      result.pruned.length > 0;

    if (!hasChanges) {
      this.output.success('Docs up to date');
      return;
    }

    // Build summary string
    const parts: string[] = [];
    if (result.added.length > 0) {
      parts.push(`+${result.added.length}`);
    }
    if (result.updated.length > 0) {
      parts.push(`~${result.updated.length}`);
    }
    if (result.removed.length > 0) {
      parts.push(`-${result.removed.length}`);
    }

    if (parts.length > 0) {
      this.output.success(`Synced docs: ${parts.join(' ')} doc(s)`);
    }

    // Report pruned entries
    if (result.pruned.length > 0) {
      this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
    }

    // Report errors
    for (const err of result.errors) {
      this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
    }
  }

  private async showIssueStatus(syncBranch: string, remote: string): Promise<void> {
    const status = await this.getSyncStatus(syncBranch, remote);

    this.output.data(status, () => {
      const colors = this.output.getColors();

      if (status.synced) {
        this.output.success('Repository is in sync');
        return;
      }

      console.log(colors.bold(`Sync status: ${syncBranch} ↔ ${remote}/${syncBranch}`));
      console.log('');

      if (status.ahead > 0) {
        console.log(`  ${colors.id(`↑ ${status.ahead}`)} commit(s) ahead (to push)`);
      }
      if (status.behind > 0) {
        console.log(`  ${colors.dim(`↓ ${status.behind}`)} commit(s) behind (to pull)`);
      }

      if (status.localChanges.length > 0) {
        console.log('');
        console.log(colors.bold('Local changes (not yet pushed):'));
        for (const change of status.localChanges) {
          console.log(`  ${change}`);
        }
      }

      if (status.remoteChanges.length > 0) {
        console.log('');
        console.log(colors.bold('Remote changes (not yet pulled):'));
        for (const change of status.remoteChanges) {
          console.log(`  ${change}`);
        }
      }
    });
  }

  private async getSyncStatus(syncBranch: string, remote: string): Promise<SyncStatus> {
    const localChanges: string[] = [];
    const remoteChanges: string[] = [];
    let ahead = 0;
    let behind = 0;

    // Check for uncommitted changes in the worktree
    // FIX Bug 2: Previously ran git status on main branch where data-sync/ is gitignored.
    // Now check worktree status directly.
    // See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
    try {
      const status = await git('-C', this.worktreePath, 'status', '--porcelain');
      if (status) {
        for (const line of status.split('\n')) {
          if (!line) continue;
          const statusCode = line.slice(0, 2).trim();
          const file = line.slice(3);
          if (statusCode === 'M') {
            localChanges.push(`modified: ${file}`);
          } else if (statusCode === 'A' || statusCode === '??') {
            localChanges.push(`new: ${file}`);
          } else if (statusCode === 'D') {
            localChanges.push(`deleted: ${file}`);
          }
        }
      }
    } catch {
      this.output.debug('Git worktree not available');
    }

    // Check for remote changes
    try {
      await git('fetch', remote, syncBranch);

      // Count commits ahead/behind
      try {
        const aheadOutput = await git(
          'rev-list',
          '--count',
          `${remote}/${syncBranch}..${syncBranch}`,
        );
        ahead = parseInt(aheadOutput, 10) || 0;
      } catch {
        this.output.debug('Branch does not exist locally');
      }

      try {
        const behindOutput = await git(
          'rev-list',
          '--count',
          `${syncBranch}..${remote}/${syncBranch}`,
        );
        behind = parseInt(behindOutput, 10) || 0;
      } catch {
        this.output.debug('Remote branch does not exist');
      }

      // Get commit messages for remote changes
      if (behind > 0) {
        const logOutput = await git(
          'log',
          '--oneline',
          `${syncBranch}..${remote}/${syncBranch}`,
          '--max-count=10',
        );
        for (const line of logOutput.split('\n')) {
          if (line) {
            remoteChanges.push(line);
          }
        }
      }
    } catch {
      this.output.debug('Remote not available or sync branch does not exist');
    }

    return {
      synced:
        localChanges.length === 0 && remoteChanges.length === 0 && ahead === 0 && behind === 0,
      localChanges,
      remoteChanges,
      syncBranch,
      remote,
      ahead,
      behind,
    };
  }

  private async pullChanges(syncBranch: string, remote: string): Promise<void> {
    const spinner = this.output.spinner('Pulling from remote...');
    try {
      await git('fetch', remote, syncBranch);

      // Get list of changed files
      let behind = 0;
      try {
        const behindOutput = await git(
          'rev-list',
          '--count',
          `${syncBranch}..${remote}/${syncBranch}`,
        );
        behind = parseInt(behindOutput, 10) || 0;
      } catch {
        this.output.debug('Branch does not exist');
      }

      spinner.stop();
      if (behind === 0) {
        this.output.success('Already up to date');
        return;
      }

      await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
      await git('-C', this.worktreePath, 'merge', '--ff-only', `${remote}/${syncBranch}`);

      this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
    } catch (error) {
      spinner.stop();
      const msg = (error as Error).message;
      if (msg.includes('not found') || msg.includes('does not exist')) {
        this.output.info(`Remote branch ${remote}/${syncBranch} does not exist yet`);
      } else {
        throw new SyncError(`Failed to pull: ${msg}`);
      }
    }
  }

  /**
   * Commit any uncommitted changes in the worktree to the sync branch.
   * This must be called before pushing to ensure changes are captured.
   *
   * @returns Tallies of new/updated/deleted files committed
   */
  private async commitWorktreeChanges(): Promise<SyncTallies> {
    // Use tbdRoot to derive worktree path consistently
    // FIX Bug 1: Previously used process.cwd() which fails if not in repo root
    // See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
    const worktreePath = this.worktreePath;

    try {
      // Ensure worktree is attached to sync branch (repair old tbd repos)
      await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);

      // Check for uncommitted changes (untracked, modified, or deleted)
      const status = await git('-C', worktreePath, 'status', '--porcelain');
      if (!status || status.trim() === '') {
        return emptyTallies(); // Nothing to commit
      }

      // Parse status to get tallies
      const tallies = parseGitStatus(status);
      const fileCount = tallies.new + tallies.updated + tallies.deleted;

      // Stage all changes
      await git('-C', worktreePath, 'add', '-A');

      // Commit the changes
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
      await gitCommit(
        worktreePath,
        '-m',
        `tbd sync: ${timestamp} (${fileCount} file${fileCount === 1 ? '' : 's'})`,
      );

      return tallies;
    } catch (error) {
      // If commit fails (e.g., nothing to commit after staging), that's ok
      const msg = (error as Error).message;
      if (msg.includes('nothing to commit')) {
        return emptyTallies();
      }
      throw error;
    }
  }

  /**
   * Abort the sync if any bead file fails to parse, naming the offending
   * file(s). Prevents the silent "received N updated" success over a store left
   * corrupted by a merge. See issue #155.
   */
  private async assertNoCorruptBeads(): Promise<void> {
    const invalid = await findInvalidBeads(this.dataSyncDir);
    if (invalid.length > 0) {
      throw new SyncError(
        `Sync left ${invalid.length} unreadable bead file(s):\n` +
          invalid.map((i) => `  - ${i.file}: ${i.reason}`).join('\n') +
          `\n\nThis is a bug in tbd sync. Please report it and resolve the file(s) in:\n` +
          `  ${this.worktreePath}`,
      );
    }
  }

  private async pushChanges(syncBranch: string, remote: string): Promise<void> {
    const spinner = this.output.spinner('Pushing to remote...');
    try {
      // Commit any uncommitted changes in the worktree before pushing
      const committedTallies = await this.commitWorktreeChanges();
      const committedCount =
        committedTallies.new + committedTallies.updated + committedTallies.deleted;
      if (committedCount > 0) {
        this.output.info(`Committed ${committedCount} file(s) to sync branch`);
      }

      // Check how many commits we're ahead of remote
      let ahead = 0;
      try {
        await git('fetch', remote, syncBranch);
        const aheadOutput = await git(
          'rev-list',
          '--count',
          `${remote}/${syncBranch}..${syncBranch}`,
        );
        ahead = parseInt(aheadOutput, 10) || 0;
        this.output.debug(`Ahead of remote by ${ahead} commit(s)`);
      } catch {
        // Remote branch doesn't exist - count all local commits
        try {
          const countOutput = await git('rev-list', '--count', syncBranch);
          ahead = parseInt(countOutput, 10) || 0;
          this.output.debug(`Remote branch not found, ${ahead} local commit(s) to push`);
        } catch {
          ahead = 0;
          this.output.debug('Could not count local commits');
        }
      }

      if (ahead === 0) {
        spinner.stop();
        this.output.success('Already up to date');
        return;
      }

      // Use push with retry
      const result = await this.doPushWithRetry(syncBranch, remote);
      spinner.stop();

      if (result.success) {
        if (result.conflicts && result.conflicts.length > 0) {
          this.output.success(
            `Pushed to ${remote}/${syncBranch} (${result.conflicts.length} conflict(s) preserved in attic)`,
          );
        } else {
          this.output.success(`Pushed ${ahead} commit(s) to ${remote}/${syncBranch}`);
        }
      } else {
        throw new SyncError(`Failed to push: ${result.error}`);
      }
    } catch (error) {
      spinner.stop();
      if (error instanceof SyncError) throw error;
      throw new SyncError(`Failed to push: ${(error as Error).message}`);
    }
  }

  /**
   * Integrate the remote sync branch into the local worktree with a real git
   * merge, resolving bead conflicts through the structured field-level engine
   * (never git's line-based text merge), reconciling ID mappings, committing the
   * result, and asserting no bead was left corrupt.
   *
   * Shared by the pull/full-sync path and the push-retry path so both integrate
   * the remote identically — and so a rejected push actually advances local
   * `tbd-sync` to include the remote before retrying, instead of looping on the
   * same non-fast-forward commit. See issue #155.
   *
   * @returns field-level conflict entries (the caller preserves them in attic).
   */
  private async mergeRemoteIntoSyncBranch(
    syncBranch: string,
    remote: string,
  ): Promise<ConflictEntry[]> {
    const worktreePath = this.worktreePath;
    const conflicts: ConflictEntry[] = [];

    // Track HEAD before merge for debug log
    let headBeforeMerge = '';
    try {
      headBeforeMerge = (await git('-C', worktreePath, 'rev-parse', 'HEAD')).trim();
    } catch {
      // Ignore - just won't show debug log
    }

    // Merge remote into local using worktree
    // This is a proper git merge that preserves both local and remote changes
    try {
      await git(
        '-C',
        worktreePath,
        'merge',
        `${remote}/${syncBranch}`,
        '-m',
        'tbd sync: merge remote changes',
      );
      this.output.debug('Merged remote changes');

      // Show received commits in debug mode
      // Use syncBranch explicitly — bare `HEAD` would resolve to the user's
      // current working branch, not the tbd-sync branch in the worktree.
      if (headBeforeMerge) {
        await this.showGitLogDebug('Commits received', `${headBeforeMerge}..${syncBranch}`);
      }

      // Reconcile ID mappings after clean merge.
      // A git merge may add issue files without corresponding ids.yml entries
      // (e.g., when outbox issues were committed to a feature branch).
      // Try to recover original short IDs from the remote's mapping to preserve
      // ID stability (so existing references in docs/PRs remain valid).
      const postMergeIssues = await listIssues(this.dataSyncDir);
      const postMergeMapping = await loadIdMapping(this.dataSyncDir);

      // Load historical mapping from remote to recover original short IDs
      let historicalMapping: Awaited<ReturnType<typeof loadIdMapping>> | undefined;
      try {
        const remoteIdsContent = await git(
          'show',
          `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`,
        );
        if (remoteIdsContent) {
          historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
        }
      } catch {
        // Remote mapping not available - will generate new IDs
      }

      const reconcileResult = reconcileMappings(
        postMergeIssues.map((i) => i.id),
        postMergeMapping,
        historicalMapping,
      );
      const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
      if (totalReconciled > 0) {
        await saveIdMapping(this.dataSyncDir, postMergeMapping);
        // Commit the updated mapping so it's included in the push
        await git('-C', worktreePath, 'add', '-A');
        try {
          await gitCommit(
            worktreePath,
            '--no-verify',
            '-m',
            `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`,
          );
        } catch {
          // Nothing to commit if mapping file was unchanged
        }
        if (reconcileResult.recovered.length > 0) {
          this.output.debug(
            `Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`,
          );
        }
        if (reconcileResult.created.length > 0) {
          this.output.debug(
            `Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`,
          );
        }
      }
    } catch {
      // Merge conflict - try to resolve at file level
      this.output.info(`Merge conflict, attempting file-level resolution`);

      // Resolve each conflicted bead with a structured three-way merge read
      // from git refs (HEAD = ours, MERGE_HEAD = theirs). Reading committed
      // blobs means a marker-corrupted working file is never parsed, and the
      // real merge-base lets union fields (e.g. child_order_hints) combine
      // both sides instead of one winning. See issue #155.
      const conflictedList = await git(
        '-C',
        worktreePath,
        'diff',
        '--name-only',
        '--diff-filter=U',
        '--',
        `${DATA_SYNC_DIR}/issues`,
      );
      const conflictedIds = conflictedList
        .split('\n')
        .map((f) => f.trim())
        .filter((f) => f.endsWith('.md'))
        .map((f) => basename(f, '.md'));
      for (const id of conflictedIds) {
        // The helper returns null only for a legitimately absent bead. Any
        // thrown error is corruption (e.g. a committed bead that fails to
        // parse) or an unexpected git failure — fail loudly rather than
        // skip, so we never silently drop a conflicted bead. (#155 review)
        let result: Awaited<ReturnType<typeof mergeBeadAcrossRefs>>;
        try {
          result = await mergeBeadAcrossRefs(worktreePath, id, 'HEAD', 'MERGE_HEAD');
        } catch (error) {
          throw new SyncError(
            `Failed to merge bead ${id} during sync: ${(error as Error).message}`,
          );
        }
        if (result) {
          await writeIssue(this.dataSyncDir, result.merged);
          conflicts.push(...result.conflicts);
        }
      }

      // Merge ids.yml (ID mappings are always additive, so we union both sides)
      // Also capture the remote mapping for recovery of original short IDs.
      // The on-disk ids.yml may contain conflict markers after a failed git merge,
      // so we resolve conflicts by extracting both sides and merging.
      let conflictRemoteMapping: Awaited<ReturnType<typeof loadIdMapping>> | undefined;
      try {
        const remoteIdsContent = await git(
          'show',
          `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`,
        );
        if (remoteIdsContent) {
          conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
          // Read the on-disk file (which may have conflict markers) and resolve
          const idsPath = join(this.dataSyncDir, 'mappings', 'ids.yml');
          const rawContent = await readFile(idsPath, 'utf-8');
          const localMapping = resolveIdMappingConflicts(rawContent);
          const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
          await saveIdMapping(this.dataSyncDir, mergedMapping);
          this.output.debug(
            `Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`,
          );
        }
      } catch (error) {
        // Remote ids.yml doesn't exist or can't be parsed - keep local
        this.output.debug(`Could not merge ids.yml: ${(error as Error).message}`);
      }

      // Reconcile any remaining issues without mappings after conflict resolution.
      // Use the remote mapping as historical source to recover original short IDs.
      {
        const allIssues = await listIssues(this.dataSyncDir);
        const currentMapping = await loadIdMapping(this.dataSyncDir);
        const reconcileResult = reconcileMappings(
          allIssues.map((i) => i.id),
          currentMapping,
          conflictRemoteMapping,
        );
        const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
        if (totalReconciled > 0) {
          await saveIdMapping(this.dataSyncDir, currentMapping);
          if (reconcileResult.recovered.length > 0) {
            this.output.debug(
              `Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`,
            );
          }
          if (reconcileResult.created.length > 0) {
            this.output.debug(
              `Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`,
            );
          }
        }
      }

      // Stage resolved files and complete merge
      // Use --no-verify to bypass parent repo hooks (lefthook, husky, etc.)
      await git('-C', worktreePath, 'add', '-A');

      // SAFETY CHECK: Never commit files with unresolved merge conflict markers
      // This prevents the bug where ids.yml or other files get committed with
      // <<<<<<< HEAD markers still present
      const conflictCheck = await git(
        '-C',
        worktreePath,
        'diff',
        '--cached',
        '-S<<<<<<< ',
        '--name-only',
      );
      if (conflictCheck.trim()) {
        const conflictedFiles = conflictCheck.trim().split('\n');
        throw new SyncError(
          `Cannot commit: ${conflictedFiles.length} file(s) still have merge conflict markers:\n` +
            conflictedFiles.map((f) => `  - ${f}`).join('\n') +
            `\n\nThis is a bug in tbd sync. Please report it and manually resolve conflicts in:\n` +
            `  ${worktreePath}`,
        );
      }

      try {
        await gitCommit(worktreePath, '--no-verify', '-m', 'tbd sync: resolved merge conflicts');
      } catch {
        // May fail if no conflicts needed resolving
        this.output.debug('No merge commit needed (conflicts already resolved)');
      }
    }

    // Never report a successful sync over a corrupted store: if the merge
    // left any bead unparseable, fail loudly and name it. See issue #155.
    await this.assertNoCorruptBeads();
    return conflicts;
  }

  private async doPushWithRetry(syncBranch: string, remote: string): Promise<PushResult> {
    return pushWithRetry(
      syncBranch,
      remote,
      async () => {
        // Merge callback — invoked when a push is rejected because the remote
        // advanced after our fetch. Do a REAL merge of the remote into the local
        // sync branch (committing the integration) via the same path the pull
        // uses, so the retried push fast-forwards instead of being rejected
        // again on the same non-fast-forward commit. See issue #155.
        return this.mergeRemoteIntoSyncBranch(syncBranch, remote);
      },
      this.tbdRoot,
    );
  }

  /**
   * Show git log --stat output in debug mode.
   * Used to display commits that were synced.
   *
   * @param label - Label for the debug output (e.g., "Commits sent")
   * @param args - Arguments to pass to `git log` after `--stat --oneline`
   *   Must include explicit branch/ref to avoid showing commits from the wrong branch.
   */
  private async showGitLogDebug(label: string, ...args: string[]): Promise<void> {
    try {
      const logOutput = await git('log', '--stat', '--oneline', ...args);
      if (logOutput.trim()) {
        this.output.debug(`${label}:`);
        for (const line of logOutput.split('\n')) {
          this.output.debug(`  ${line}`);
        }
      }
    } catch {
      // Ignore errors - log is just for debugging
    }
  }

  private async fullSync(
    syncBranch: string,
    remote: string,
    options: { force?: boolean; autoSave?: boolean; outbox?: boolean } = {},
  ): Promise<void> {
    const spinner = this.output.spinner('Syncing with remote...');
    const summary: SyncSummary = emptySummary();
    const conflicts: ConflictEntry[] = [];
    // Use tbdRoot for consistent path resolution
    const worktreePath = this.worktreePath;

    try {
      // STEP 1: Commit local changes FIRST (before pulling)
      // This ensures local work is preserved before we incorporate remote changes.
      const committedTallies = await this.commitWorktreeChanges();
      // Add committed changes to sent tallies
      summary.sent.new += committedTallies.new;
      summary.sent.updated += committedTallies.updated;
      summary.sent.deleted += committedTallies.deleted;
      if (hasTallies(committedTallies)) {
        const count = committedTallies.new + committedTallies.updated + committedTallies.deleted;
        this.output.debug(`Committed ${count} file(s) to sync branch`);
      }

      // STEP 2: Fetch remote
      await git('fetch', remote, syncBranch);

      // Detect unrelated histories UP FRONT (post-fetch), before any merge or
      // conflict/retry work — so sync does no misleading work, diagnostics
      // point at the right phase, and pushWithRetry is never run against an
      // unrelated remote (no wasted retries). The rescue is `tbd doctor --fix`.
      if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) {
        throw new UnrelatedHistoriesError(remote, syncBranch);
      }

      // Get file-level changes from remote using git diff
      let behindCommits = 0;
      try {
        const behindOutput = await git(
          'rev-list',
          '--count',
          `${syncBranch}..${remote}/${syncBranch}`,
        );
        behindCommits = parseInt(behindOutput, 10) || 0;
        this.output.debug(`Behind remote by ${behindCommits} commit(s)`);

        // Get file-level tallies for received changes
        if (behindCommits > 0) {
          try {
            const diffOutput = await git(
              'diff',
              '--name-status',
              `${syncBranch}..${remote}/${syncBranch}`,
            );
            const receivedTallies = parseGitDiff(diffOutput);
            summary.received.new += receivedTallies.new;
            summary.received.updated += receivedTallies.updated;
            summary.received.deleted += receivedTallies.deleted;
          } catch {
            // If we can't get detailed diff, just track commit count
            this.output.debug('Could not get detailed diff for received changes');
          }
        }
      } catch {
        // Branch doesn't exist on remote
        this.output.debug('Remote sync branch does not exist yet');
      }

      // Ensure .gitattributes exists in the worktree so ids.yml uses merge=union.
      // This prevents conflicts when both sides add non-overlapping keys.
      // Written before every merge so existing repos get it on their next sync.
      {
        const attrPath = join(this.dataSyncDir, 'mappings', '.gitattributes');
        try {
          await access(attrPath);
        } catch {
          await writeFile(attrPath, 'ids.yml merge=union\n');
          await git('-C', worktreePath, 'add', attrPath);
          try {
            await gitCommit(
              worktreePath,
              '--no-verify',
              '-m',
              'chore: add merge=union for ids.yml',
            );
          } catch {
            // May fail if nothing to commit (already staged elsewhere)
          }
        }
      }

      // STEP 3: If remote has changes, merge them in
      if (behindCommits > 0) {
        const mergeConflicts = await this.mergeRemoteIntoSyncBranch(syncBranch, remote);
        conflicts.push(...mergeConflicts);
      }
    } catch (error) {
      // Surface real sync failures (e.g. unrelated histories) instead of
      // masking them as "first sync". A genuine first-sync fetch failure is a
      // GitError, not a SyncError, so it is still swallowed below.
      if (error instanceof SyncError) {
        throw error;
      }
      // Remote not available - that's ok for first sync
      this.output.debug(`Fetch failed (may be first sync): ${(error as Error).message}`);
    }

    // Check how many commits we're ahead of remote (if any)
    let aheadCommits = 0;
    try {
      const aheadOutput = await git(
        'rev-list',
        '--count',
        `${remote}/${syncBranch}..${syncBranch}`,
      );
      aheadCommits = parseInt(aheadOutput, 10) || 0;
      this.output.debug(`Ahead of remote by ${aheadCommits} commit(s)`);
    } catch {
      // Remote branch doesn't exist - count all local commits on sync branch
      try {
        const countOutput = await git('rev-list', '--count', syncBranch);
        aheadCommits = parseInt(countOutput, 10) || 0;
        this.output.debug(`Remote branch not found, ${aheadCommits} local commit(s) to push`);
      } catch {
        aheadCommits = 0;
        this.output.debug('Could not count local commits');
      }
    }

    // Push if we have commits ahead of remote
    let pushFailed = false;
    let pushError = '';
    if (aheadCommits > 0) {
      this.output.debug(`Pushing ${aheadCommits} commit(s) to remote`);
      const result = await this.doPushWithRetry(syncBranch, remote);
      if (result.conflicts) {
        conflicts.push(...result.conflicts);
      }
      if (!result.success) {
        pushFailed = true;
        pushError = result.error ?? 'Unknown push error';
        this.output.debug(`Push failed: ${pushError}`);
      } else {
        // Show pushed commits in debug mode
        // Use syncBranch explicitly — bare `-N` would resolve against the user's
        // current working branch (HEAD), not the tbd-sync branch.
        await this.showGitLogDebug('Commits sent', syncBranch, `-${aheadCommits}`);
      }
    } else {
      this.output.debug('No commits to push');
    }

    summary.conflicts = conflicts.length;
    spinner.stop();

    // Report push failure - classify error and take appropriate action
    if (pushFailed) {
      // Defense in depth: a push can still fail with unrelated histories if the
      // remote became unrelated after our up-front check. Surface the dedicated
      // remediation rather than the generic "Remote has conflicting changes".
      if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) {
        throw new UnrelatedHistoriesError(remote, syncBranch);
      }

      // Extract meaningful error display string
      let displayError = pushError;
      const httpMatch = /HTTP (\d+)/.exec(pushError);
      const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
      if (httpMatch) {
        displayError = `HTTP ${httpMatch[1]}${curlMatch ? ` - ${curlMatch[1]}` : ''}`;
      } else {
        // Fall back to first meaningful line (skip "Command failed: git push...")
        const lines = pushError.split('\n').filter((l) => l && !l.startsWith('Command failed'));
        displayError = lines[0] ?? pushError;
      }

      // Classify the error to determine recovery action
      const errorType = classifySyncError(pushError);

      this.output.data(
        {
          summary,
          conflicts: conflicts.length,
          pushFailed,
          pushError,
          unpushedCommits: aheadCommits,
          errorType,
        },
        () => {
          this.output.error(`Push failed: ${displayError}`);
          console.log(`  ${aheadCommits} commit(s) not pushed to remote.`);
        },
      );

      // Handle recovery based on error type (after output.data to avoid async callback)
      // Only show options in non-JSON mode
      let recovered = false;
      if (errorType === 'permanent' && options.autoSave !== false) {
        // Auto-save to outbox on permanent failure (data durably preserved).
        recovered = await this.handlePermanentFailure();
      } else if (!this.ctx.json) {
        if (errorType === 'transient') {
          // Suggest retry for transient failures
          console.log('');
          console.log('  This appears to be a temporary issue. Options:');
          console.log('    • Retry:  tbd sync');
          console.log('    • Save for later:  tbd save --outbox');
        } else {
          // Unknown error - suggest both options
          console.log('');
          console.log('  Options:');
          console.log('    • Retry:  tbd sync');
          console.log("    • Run 'tbd sync --status' to check status");
          console.log('    • Save for later:  tbd save --outbox');
        }
      }

      // A push failure is a hard failure that CI/scripts must be able to detect,
      // UNLESS the changes were durably saved to the outbox (the soft-fail
      // recovery path). Local commits remain on tbd-sync either way, but the
      // remote was not updated. (#158)
      if (!recovered) {
        process.exitCode = 1;
      }
      return;
    }

    // After successful push, import from outbox if it has data
    if (options.outbox !== false) {
      await this.maybeImportOutbox(syncBranch, remote);
    }

    this.output.data({ summary, conflicts: conflicts.length }, () => {
      const summaryText = formatSyncSummary(summary);
      if (!summaryText) {
        this.output.success('Already in sync');
      } else {
        this.output.success(`Synced: ${summaryText}`);
      }
    });
  }

  /**
   * Handle permanent push failure by auto-saving to outbox.
   * Called when push fails with a permanent error (e.g., HTTP 403).
   *
   * @returns true if the local changes are durably safe (saved to the outbox, or
   *   nothing needed saving); false if the auto-save itself failed. The caller
   *   uses this to decide the process exit code. (#158)
   */
  private async handlePermanentFailure(): Promise<boolean> {
    // Count issues in worktree to see if there's anything to save
    const worktreeIssues = await listIssues(this.dataSyncDir);
    if (worktreeIssues.length === 0) {
      console.log('');
      console.log('  No unsynced issues to save (already in sync with remote).');
      return true;
    }

    // Check existing outbox count before save
    let existingOutboxCount = 0;
    if (await workspaceExists(this.tbdRoot, 'outbox')) {
      const outboxPath = join(this.tbdRoot, '.tbd', 'workspaces', 'outbox');
      try {
        const existingIssues = await listIssues(outboxPath);
        existingOutboxCount = existingIssues.length;
      } catch {
        // Outbox exists but couldn't read - will be handled by saveToWorkspace
      }
    }

    try {
      // Auto-save to outbox (merges with existing outbox data via updatesOnly)
      const result = await saveToWorkspace(this.tbdRoot, this.dataSyncDir, { outbox: true });

      if (result.saved === 0) {
        // Nothing new to save - issues already in outbox from previous failure
        console.log('');
        console.log('  Issues already saved to outbox from previous sync attempt.');
        console.log('');
        console.log('  Your issues are safe. To recover later:');
        console.log("    1. Commit:  git add .tbd/workspaces && git commit -m 'tbd: save outbox'");
        console.log('    2. Push your working branch:  git push');
        console.log("    3. Run 'tbd sync' when push access is available");
        console.log('');
        console.log(
          '  WARNING: Do NOT add .tbd/workspaces/ to .gitignore -- that would cause data loss.',
        );
      } else {
        // Show saved count and total in outbox
        const totalInOutbox = existingOutboxCount + result.saved;
        if (existingOutboxCount > 0) {
          console.log('');
          this.output.success(
            `Saved ${result.saved} issue(s) to outbox (${totalInOutbox} total in outbox)`,
          );
        } else {
          console.log('');
          this.output.success(`Saved ${result.saved} issue(s) to outbox (automatic backup)`);
        }
        console.log('');
        console.log('  Your issues are safe. To recover later:');
        console.log("    1. Commit:  git add .tbd/workspaces && git commit -m 'tbd: save outbox'");
        console.log('    2. Push your working branch:  git push');
        console.log("    3. Run 'tbd sync' when push access is available");
        console.log('       (outbox will be imported automatically on successful sync)');
        console.log('');
        console.log(
          '  WARNING: Do NOT add .tbd/workspaces/ to .gitignore -- that would cause data loss.',
        );
      }
      return true;
    } catch (saveError) {
      // Auto-save failed - report both errors
      const saveErrorMsg = saveError instanceof Error ? saveError.message : String(saveError);
      console.log('');
      this.output.error(`Auto-save to outbox also failed: ${saveErrorMsg}`);
      console.log('');
      console.log("  Run 'tbd save --outbox' manually, or 'tbd doctor' to diagnose.");
      return false;
    }
  }

  /**
   * Import pending issues from outbox after a successful push.
   * Uses two-phase sync: import → commit → push → clear.
   * Only clears the outbox if all steps succeed.
   *
   * @param syncBranch - The sync branch name
   * @param remote - The remote name
   */
  private async maybeImportOutbox(syncBranch: string, remote: string): Promise<void> {
    // Check if outbox exists and has issues
    if (!(await workspaceExists(this.tbdRoot, 'outbox'))) {
      return; // No outbox - nothing to import
    }

    const outboxPath = join(this.tbdRoot, '.tbd', 'workspaces', 'outbox');
    let outboxIssues: Awaited<ReturnType<typeof listIssues>> = [];
    try {
      outboxIssues = await listIssues(outboxPath);
    } catch {
      return; // Can't read outbox - skip silently
    }

    if (outboxIssues.length === 0) {
      return; // Outbox is empty - nothing to import
    }

    try {
      // Step 1: Import from outbox (don't clear yet)
      const importResult = await importFromWorkspace(this.tbdRoot, this.dataSyncDir, {
        outbox: true,
        clearOnSuccess: false, // We'll clear manually after push succeeds
      });

      if (importResult.imported === 0) {
        return; // Nothing was actually imported
      }

      // Step 2: Commit the imported issues
      const committedTallies = await this.commitWorktreeChanges();
      if (committedTallies.new + committedTallies.updated + committedTallies.deleted === 0) {
        // Nothing to commit - issues were already in worktree
        // This can happen if outbox data was identical to worktree
        // Still clear the outbox since the data is already synced
        await deleteWorkspace(this.tbdRoot, 'outbox');
        return;
      }

      // Step 3: Push the imported issues
      const pushResult = await this.doPushWithRetry(syncBranch, remote);
      if (!pushResult.success) {
        // Secondary push failed - DON'T clear outbox
        // The issues are now in the worktree, so they'll be synced next time
        this.output.warn(
          `Could not push imported outbox issues: ${pushResult.error ?? 'unknown error'}`,
        );
        console.log('  Outbox preserved. Issues are in worktree and will sync next time.');
        return;
      }

      // Step 4: All succeeded - now clear the outbox
      await deleteWorkspace(this.tbdRoot, 'outbox');

      this.output.success(`Imported ${importResult.imported} issue(s) from outbox (also synced)`);
    } catch (err) {
      // Don't fail the whole sync - primary sync already succeeded
      const errMsg = err instanceof Error ? err.message : String(err);
      this.output.warn(`Could not sync outbox: ${errMsg}`);
      console.log('  Outbox preserved. Will retry on next sync.');
    }
  }
}

export const syncCommand = new Command('sync')
  .description('Synchronize issues and docs (both by default)')
  .option('--issues', 'Sync only issues (not docs)')
  .option('--docs', 'Sync only docs (not issues)')
  .option('--push', 'Push local issue changes only')
  .option('--pull', 'Pull remote issue changes only')
  .option('--status', 'Show sync status')
  .option('--force', 'Force sync (overwrite conflicts)')
  .option('--fix', 'Attempt to repair unhealthy worktree before syncing')
  .option('--no-auto-save', 'Skip auto-save to outbox on permanent failure')
  .option('--no-outbox', 'Skip auto-import from outbox on success')
  .action(async (options, command) => {
    const handler = new SyncHandler(command);
    await handler.run(options);
  });
